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 $deployedWorkflows Map populated by reference * @param string $importSource Import source identifier * + * @param-out array $deployedWorkflows A workflow named + * with a canonical numeric string ("12") gets an INT key from PHP's array-key + * coercion, so the map that comes back out is not key-narrowable to string. + * * @return array Updated result array * * @SuppressWarnings(PHPMD.CyclomaticComplexity) @@ -4303,7 +4307,7 @@ private function autoCreateRegisterIfApplication( // circuits when the table already exists (see MagicTableHandler // line 109-112). The seed-objects loop further down still calls // the same method as a defensive no-op. - if ($this->magicMapper !== null && $register !== null) { + if ($this->magicMapper !== null) { foreach ($schemas as $schema) { if ($schema instanceof Schema === false) { continue; diff --git a/lib/Service/CospendLinkService.php b/lib/Service/CospendLinkService.php index cde7c5994b..4618591f1b 100644 --- a/lib/Service/CospendLinkService.php +++ b/lib/Service/CospendLinkService.php @@ -495,7 +495,7 @@ public function getLinkedEntries(string $objectUuid): array { if ($projectId !== '') { $billId = ($row['billId'] ?? ''); $billPath = ''; - if ($billId !== '' && $billId !== null) { + if ($billId !== '') { $billPath = '/b/' . rawurlencode((string)$billId); } diff --git a/lib/Service/Dbal/DbalConnectionFactory.php b/lib/Service/Dbal/DbalConnectionFactory.php index fc50098671..43b6031106 100644 --- a/lib/Service/Dbal/DbalConnectionFactory.php +++ b/lib/Service/Dbal/DbalConnectionFactory.php @@ -219,7 +219,7 @@ private function driverSpecificParams(string $driver, array $config): array { 'user' => (string)($config['user'] ?? ''), ]; - if (isset($config['port']) === true && $config['port'] !== null && $config['port'] !== '') { + if (isset($config['port']) === true && $config['port'] !== '') { $params['port'] = (int)$config['port']; } diff --git a/lib/Service/DeepLinkRegistryService.php b/lib/Service/DeepLinkRegistryService.php index f2b7fd3eb1..791a370283 100644 --- a/lib/Service/DeepLinkRegistryService.php +++ b/lib/Service/DeepLinkRegistryService.php @@ -57,19 +57,12 @@ class DeepLinkRegistryService { */ private static array $registrations = []; - /** - * Cached slug→ID map for registers. Null means not yet loaded. - * - * @var array|null - */ - private static ?array $registerSlugMap = null; - - /** - * Cached slug→ID map for schemas. Null means not yet loaded. - * - * @var array|null + /* + * There are no $registerSlugMap / $schemaSlugMap properties: the forward + * slug→ID direction was declared and reset but never populated or read, so + * both were permanently null. Only the reverse ID→slug maps below are used + * (by ensureIdMaps() and resolveDeepLink()). */ - private static ?array $schemaSlugMap = null; /** * Cached reverse map: ID→slug for registers. @@ -361,8 +354,6 @@ public function hasRegistrations(): bool { */ public static function reset(): void { self::$registrations = []; - self::$registerSlugMap = null; - self::$schemaSlugMap = null; self::$registerIdMap = null; self::$schemaIdMap = null; }//end reset() diff --git a/lib/Service/EmailLinkService.php b/lib/Service/EmailLinkService.php index f1724aa394..6f7e777ba3 100644 --- a/lib/Service/EmailLinkService.php +++ b/lib/Service/EmailLinkService.php @@ -631,7 +631,7 @@ private function normalizeMessageRows(array $rows): array { foreach ($rows as $row) { $id = (int)($row['id'] ?? 0); $sentAt = null; - if (isset($row['sent_at']) === true && $row['sent_at'] !== null) { + if (isset($row['sent_at']) === true) { $sentAt = date('c', (int)$row['sent_at']); } @@ -793,7 +793,7 @@ private function fetchMessageRow(int $mailAccountId, int $mailMessageId): ?array } $sentAt = null; - if (isset($row['sent_at']) === true && $row['sent_at'] !== null) { + if (isset($row['sent_at']) === true) { $sentAt = date('c', (int)$row['sent_at']); } diff --git a/lib/Service/EmailService.php b/lib/Service/EmailService.php index bb53c8edd6..93bca75fda 100644 --- a/lib/Service/EmailService.php +++ b/lib/Service/EmailService.php @@ -208,7 +208,7 @@ public function linkEmail( $link->setLinkedBy($user->getUID()); $link->setLinkedAt(new DateTime()); - if (isset($messageData['date']) === true && $messageData['date'] !== null) { + if (isset($messageData['date']) === true) { $link->setMailDate(new DateTime($messageData['date'])); } @@ -408,7 +408,7 @@ private function fetchMailMessage(int $messageId, int $accountId): ?array { } $sentAt = null; - if (isset($row['sent_at']) === true && $row['sent_at'] !== null) { + if (isset($row['sent_at']) === true) { $sentAt = date('c', (int)$row['sent_at']); } diff --git a/lib/Service/File/DocumentProcessingHandler.php b/lib/Service/File/DocumentProcessingHandler.php index d6c23d33d5..ef093c7b56 100644 --- a/lib/Service/File/DocumentProcessingHandler.php +++ b/lib/Service/File/DocumentProcessingHandler.php @@ -142,7 +142,10 @@ class DocumentProcessingHandler { * `[: ]` from the global id. Empty when the last run * matched no catalogue entity. * - * @var array + * Keys are int|string, not string: entity ids are canonical numeric strings, + * which PHP coerces to int array keys. + * + * @var array */ private array $lastPlaceholderMap = []; @@ -373,11 +376,10 @@ public function anonymizeDocument( // Resolve the source file id once — the substitution placeholder // format and the post-redaction audit flag both key off it. $fileId = 0; - if (method_exists($node, 'getId') === true) { - $candidate = $node->getId(); - if (is_int($candidate) === true && $candidate > 0) { - $fileId = $candidate; - } + // OCP\Files\Node declares getId(): int, so no method_exists() probe. + $candidate = $node->getId(); + if ($candidate > 0) { + $fileId = $candidate; } // Defensive filter — per the `entity-relation-grondslagen` change, @@ -1148,7 +1150,7 @@ private function replaceWordsInWordDocument( return $newFile; } catch (Exception $e) { // Clean up temp file if it exists. - if (isset($tempFile) === true && file_exists($tempFile) === true) { + if (file_exists($tempFile) === true) { unlink($tempFile); } diff --git a/lib/Service/File/ReadFileHandler.php b/lib/Service/File/ReadFileHandler.php index 5724df78e0..3b2dbd09ad 100644 --- a/lib/Service/File/ReadFileHandler.php +++ b/lib/Service/File/ReadFileHandler.php @@ -138,7 +138,7 @@ public function getFile(ObjectEntity|string|null $object = null, string|int $fil $folder = $this->folderMgmtHandler->getObjectFolder($object); // If $file is an integer or a string that is an integer, treat as file ID. - if (is_int($file) === true || (is_string($file) === true && ctype_digit($file) === true) === true) { + if (is_int($file) === true || ctype_digit($file) === true) { // Try to get the file by ID. try { $nodes = $folder->getById((int)$file); diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index a19fb40f41..5b8fd86e09 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -1534,8 +1534,9 @@ static function ($tagName) { ); // Return sorted array of tag names. + // sort() already reindexes to a list, so no array_values(). sort($tagNames); - return array_values($tagNames); + return $tagNames; }//end getAllTags() /** diff --git a/lib/Service/GraphQL/QueryComplexityAnalyzer.php b/lib/Service/GraphQL/QueryComplexityAnalyzer.php index ce8e451ae5..b61085f2ab 100644 --- a/lib/Service/GraphQL/QueryComplexityAnalyzer.php +++ b/lib/Service/GraphQL/QueryComplexityAnalyzer.php @@ -200,15 +200,14 @@ private function analyzeSelectionSet( $totalCost += ($childResult['cost'] * $multiplier); } } elseif ($selection instanceof InlineFragmentNode) { - if ($selection->selectionSet !== null) { - $result = $this->analyzeSelectionSet( - selectionSet: $selection->selectionSet, - currentDepth: $currentDepth, - variables: $variables - ); - $maxDepth = max($maxDepth, $result['depth']); - $totalCost += $result['cost']; - } + // InlineFragmentNode::$selectionSet is non-nullable. + $result = $this->analyzeSelectionSet( + selectionSet: $selection->selectionSet, + currentDepth: $currentDepth, + variables: $variables + ); + $maxDepth = max($maxDepth, $result['depth']); + $totalCost += $result['cost']; }//end if }//end foreach diff --git a/lib/Service/GraphQL/SchemaGenerator.php b/lib/Service/GraphQL/SchemaGenerator.php index 2675ad2e14..b397409722 100644 --- a/lib/Service/GraphQL/SchemaGenerator.php +++ b/lib/Service/GraphQL/SchemaGenerator.php @@ -509,11 +509,18 @@ private function buildObjectFields(RegisterSchema $schema): array { $description = null; } - // Annotate authorization requirements in description. - if (isset($authInfo[$name]) === true && $description !== null && $description !== '') { - $description = $description . '. ' . $authInfo[$name]; - } elseif (isset($authInfo[$name]) === true) { - $description = $authInfo[$name]; + // Annotate authorization requirements in description. Nest the + // null test rather than repeating isset() in an elseif: the two + // branches differ only in whether there is an existing description + // to append to, and a property with no description must NOT come + // out as ". Requires ...". + if (isset($authInfo[$name]) === true) { + $prefix = ''; + if ($description !== null) { + $prefix = $description . '. '; + } + + $description = $prefix . $authInfo[$name]; } $fields[$fieldName] = [ diff --git a/lib/Service/GraphQL/SchemaGenerator/CompositionHandler.php b/lib/Service/GraphQL/SchemaGenerator/CompositionHandler.php index 78fc9d6a0d..63fd60c749 100644 --- a/lib/Service/GraphQL/SchemaGenerator/CompositionHandler.php +++ b/lib/Service/GraphQL/SchemaGenerator/CompositionHandler.php @@ -114,10 +114,9 @@ public function applyComposition(RegisterSchema $schema, array &$fields): void { * @spec openspec/specs/graphql-api/spec.md */ private function applyAllOf(RegisterSchema $schema, array &$fields): void { - $allOf = null; - if (method_exists(object_or_class: $schema, method: 'getAllOf') === true) { - $allOf = $schema->getAllOf(); - } + // Schema declares getAllOf(), so no method_exists() probe is needed; the + // is_array() check below still does real work because it can return null. + $allOf = $schema->getAllOf(); if (is_array(value: $allOf) === false) { return; @@ -155,10 +154,8 @@ private function applyAllOf(RegisterSchema $schema, array &$fields): void { * @spec openspec/specs/graphql-api/spec.md */ private function applyOneOf(RegisterSchema $schema, array &$fields): void { - $oneOf = null; - if (method_exists(object_or_class: $schema, method: 'getOneOf') === true) { - $oneOf = $schema->getOneOf(); - } + // Schema declares getOneOf(); see applyAllOf() above. + $oneOf = $schema->getOneOf(); if (is_array(value: $oneOf) === false || empty($oneOf) === true) { return; @@ -199,10 +196,8 @@ private function applyOneOf(RegisterSchema $schema, array &$fields): void { * @spec openspec/specs/graphql-api/spec.md */ private function applyAnyOf(RegisterSchema $schema, array &$fields): void { - $anyOf = null; - if (method_exists(object_or_class: $schema, method: 'getAnyOf') === true) { - $anyOf = $schema->getAnyOf(); - } + // Schema declares getAnyOf(); see applyAllOf() above. + $anyOf = $schema->getAnyOf(); if (is_array(value: $anyOf) === false || empty($anyOf) === true) { return; diff --git a/lib/Service/GraphQL/SchemaGenerator/TypeMapperHandler.php b/lib/Service/GraphQL/SchemaGenerator/TypeMapperHandler.php index 4813d87a42..9cb9d54b26 100644 --- a/lib/Service/GraphQL/SchemaGenerator/TypeMapperHandler.php +++ b/lib/Service/GraphQL/SchemaGenerator/TypeMapperHandler.php @@ -837,10 +837,8 @@ private function getSelfFilterType(): InputObjectType { */ public function getPropertyAuthDescriptions(RegisterSchema $schema): array { $result = []; - if (method_exists(object_or_class: $schema, method: 'getPropertiesWithAuthorization') === false) { - return $result; - } - + // Schema declares getPropertiesWithAuthorization(), so the early return + // that used to guard this call was unreachable. $authProps = $schema->getPropertiesWithAuthorization(); foreach ($authProps as $propName => $authConfig) { $groups = []; diff --git a/lib/Service/ImportService.php b/lib/Service/ImportService.php index 160c159efc..63e73ec332 100644 --- a/lib/Service/ImportService.php +++ b/lib/Service/ImportService.php @@ -323,7 +323,13 @@ private function isUserAdmin(?IUser $user): bool { * debug?: array, * schema?: array{id: int, slug: null|string, title: null|string}, * deduplication_efficiency?: string - * }> + * }|string> + * + * The `|string` is not slack: the result is a map of sheet title => summary, + * but the method also writes a scalar `importJobId` into the SAME map. A + * caller that iterates the result and assumes every value is a summary array + * will hit that key. Kept as-is because moving it would change the response + * shape for existing clients; the union at least makes it visible. * * @psalm-return arraytransformCsvRowToObject( + // transformCsvRowToObject() is declared non-nullable, so no guard. + $allObjects[] = $this->transformCsvRowToObject( rowData: $rowData, register: $register, schema: $schema, currentUser: $currentUser ); - - if ($object !== null) { - $allObjects[] = $object; - } }//end for $summary['found'] = count($allObjects); diff --git a/lib/Service/Integration/BuiltinProviders/AuditTrailProvider.php b/lib/Service/Integration/BuiltinProviders/AuditTrailProvider.php index 5a0cbd52cb..59de92b1ef 100644 --- a/lib/Service/Integration/BuiltinProviders/AuditTrailProvider.php +++ b/lib/Service/Integration/BuiltinProviders/AuditTrailProvider.php @@ -142,25 +142,26 @@ public function list(string $register, string $schema, string $objectId, array $ return $this->normalize(entries: $entries); } - if (method_exists($this->mapper, 'findAll') === true) { - // The audit table's `object` column is INTEGER (numeric - // object id) while `object_uuid` is the UUID string the - // sub-resource controller actually receives. Try the - // UUID-typed filter first and fall back to `object` for - // older schemas. - try { - $entries = $this->mapper->findAll(filters: ['object_uuid' => $objectId]); - if (is_array($entries) === true && count($entries) > 0) { - return $this->normalize(entries: $entries); - } - } catch (\Throwable $e) { - // Fall through to legacy filter. - unset($e); + // No method_exists() probe for findAll(): AuditTrailMapper declares + // it. The findAllByObject() probe above IS needed — that one is not + // on the mapper. + // + // The audit table's `object` column is INTEGER (numeric object id) + // while `object_uuid` is the UUID string the sub-resource controller + // actually receives. Try the UUID-typed filter first and fall back to + // `object` for older schemas. + try { + $entries = $this->mapper->findAll(filters: ['object_uuid' => $objectId]); + if (count($entries) > 0) { + return $this->normalize(entries: $entries); } - - $entries = $this->mapper->findAll(filters: ['object' => $objectId]); - return $this->normalize(entries: $entries); + } catch (\Throwable $e) { + // Fall through to legacy filter. + unset($e); } + + $entries = $this->mapper->findAll(filters: ['object' => $objectId]); + return $this->normalize(entries: $entries); } catch (\Throwable $e) { // AuditTrail history is a soft surface — never block the // detail page on a stale or missing audit row. diff --git a/lib/Service/Integration/Providers/SharesProvider.php b/lib/Service/Integration/Providers/SharesProvider.php index 9962d2e56a..3edf15147b 100644 --- a/lib/Service/Integration/Providers/SharesProvider.php +++ b/lib/Service/Integration/Providers/SharesProvider.php @@ -634,16 +634,11 @@ private function normaliseShare(IShare $share, string $userId): ?array { } $owner = (string)($share->getSharedBy() ?? ''); + // IShare::getNode() is declared to return Node and throws when the + // node is gone, so the null guards here were unreachable. $node = $share->getNode(); - $nodeName = ''; - if ($node !== null) { - $nodeName = (string)$node->getName(); - } - - $nodeId = 0; - if ($node !== null) { - $nodeId = (int)$node->getId(); - } + $nodeName = (string)$node->getName(); + $nodeId = (int)$node->getId(); $nodeUrl = '/index.php/apps/files'; if ($nodeId > 0) { diff --git a/lib/Service/MappingService.php b/lib/Service/MappingService.php index aa091a939b..b7eedf0717 100644 --- a/lib/Service/MappingService.php +++ b/lib/Service/MappingService.php @@ -348,9 +348,8 @@ public function executeMapping(Mapping $mapping, array $input, bool $list = fals $cast = explode(',', (string)$cast); } - if ($cast === false) { - continue; - } + // No `$cast === false` bail-out: after the branch above $cast is + // always an array (explode() cannot return false), so it never fired. foreach ($cast as $singleCast) { $this->handleCast(dotArray: $dotArray, key: $key, cast: $singleCast); @@ -382,10 +381,8 @@ public function executeMapping(Mapping $mapping, array $input, bool $list = fals $output = []; } - if (is_array($output) === false) { - $output = [$output]; - } - + // No scalar-wrapping branch: after the null default above, $output is + // always an array, so `is_array() === false` could never be entered. return $output; }//end executeMapping() diff --git a/lib/Service/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php index 0c5289db2e..f819902329 100644 --- a/lib/Service/Notification/AnnotationNotificationDispatcher.php +++ b/lib/Service/Notification/AnnotationNotificationDispatcher.php @@ -798,15 +798,15 @@ public function dispatchQueued(array $queuedRows): void { $object = new ObjectEntity(); $object->setUuid((string)($payload['objectUuid'] ?? '')); - if (isset($payload['objectRegister']) === true && $payload['objectRegister'] !== null) { + if (isset($payload['objectRegister']) === true) { $object->setRegister((string)$payload['objectRegister']); } - if (isset($payload['objectSchema']) === true && $payload['objectSchema'] !== null) { + if (isset($payload['objectSchema']) === true) { $object->setSchema((string)$payload['objectSchema']); } - if (isset($payload['objectName']) === true && $payload['objectName'] !== null) { + if (isset($payload['objectName']) === true) { $object->setName((string)$payload['objectName']); } diff --git a/lib/Service/Notification/NotificationAnnotationValidator.php b/lib/Service/Notification/NotificationAnnotationValidator.php index 4cb60455f8..2d99bacb54 100644 --- a/lib/Service/Notification/NotificationAnnotationValidator.php +++ b/lib/Service/Notification/NotificationAnnotationValidator.php @@ -457,7 +457,7 @@ public function validate(array $schema): array { } // When the `webhook` channel is declared, the spec MUST include a `webhook.url` value. - if (is_array($channels) === true && in_array('webhook', $channels, true) === true) { + if (in_array('webhook', $channels, true) === true) { $hook = ($spec['webhook'] ?? null); $hookBad = is_array($hook) === false; if ($hookBad === false) { @@ -477,7 +477,7 @@ public function validate(array $schema): array { } // When the `talk` channel is declared, the spec MUST include a `talk.token`. - if (is_array($channels) === true && in_array('talk', $channels, true) === true) { + if (in_array('talk', $channels, true) === true) { $talk = ($spec['talk'] ?? null); $talkBad = is_array($talk) === false; if ($talkBad === false) { diff --git a/lib/Service/OasService.php b/lib/Service/OasService.php index 79eb6e1d96..a5936691bf 100644 --- a/lib/Service/OasService.php +++ b/lib/Service/OasService.php @@ -263,7 +263,7 @@ public function createOas(?string $registerId = null, bool $strict = false): arr $sanitizedSchemaName = $this->sanitizeSchemaName(title: $schemaTitle); // Step 8c: Validate schema definition before adding to components. - if (empty($schemaDefinition) === false && is_array($schemaDefinition) === true) { + if (empty($schemaDefinition) === false) { $this->oas['components']['schemas'][$sanitizedSchemaName] = $schemaDefinition; // Add tag for the schema (keep original title for display). diff --git a/lib/Service/Object/CacheHandler.php b/lib/Service/Object/CacheHandler.php index 0282a3bdaf..9b7a89209a 100644 --- a/lib/Service/Object/CacheHandler.php +++ b/lib/Service/Object/CacheHandler.php @@ -95,7 +95,10 @@ class CacheHandler { * Provides ultra-fast name lookups for frontend rendering without * requiring full object data retrieval. * - * @var array + * Keys are int|string, not string: the cache is written under BOTH the uuid + * and the numeric id, and PHP coerces a canonical numeric string key to int. + * + * @var array */ private array $nameCache = []; @@ -107,7 +110,8 @@ class CacheHandler { * $nameCache; an identifier missing from this map has an unknown owner and is * therefore never served (fail closed). * - * @var array + * @var array Keyed identically to $nameCache — see + * the key-coercion note there. */ private array $nameOrganisations = []; @@ -1370,26 +1374,27 @@ public function getSingleObjectName(string|int $identifier): ?string { try { // STEP 1: Try to find as organisation first (they take priority). try { + // The findByUuid() call throws rather than returning null; the catch below + // is the "no such organisation" path. $organisation = $this->organisationMapper->findByUuid((string)$identifier); - if ($organisation !== null) { - $name = $organisation->getName() ?? $organisation->getUuid(); - // An organisation's own tenancy is itself. A row with neither a - // name nor a uuid has nothing to cache — setObjectName() takes a - // non-nullable string, so guard rather than fatal. - if ($name !== null) { - $this->setObjectName( - identifier: $identifier, - name: $name, - organisation: $organisation->getUuid() - ); - } + $name = $organisation->getName() ?? $organisation->getUuid(); - if ($this->hasOrganisationAccess(organisation: $organisation->getUuid()) === false) { - return null; - } + // An organisation's own tenancy is itself. A row with neither a + // name nor a uuid has nothing to cache — setObjectName() takes a + // non-nullable string, so guard rather than fatal. + if ($name !== null) { + $this->setObjectName( + identifier: $identifier, + name: $name, + organisation: $organisation->getUuid() + ); + } - return $name; + if ($this->hasOrganisationAccess(organisation: $organisation->getUuid()) === false) { + return null; } + + return $name; } catch (\Exception $e) { // Organisation not found, continue to objects. } diff --git a/lib/Service/Object/CascadingHandler.php b/lib/Service/Object/CascadingHandler.php index 7244e37ad4..bf509e07d8 100644 --- a/lib/Service/Object/CascadingHandler.php +++ b/lib/Service/Object/CascadingHandler.php @@ -131,30 +131,29 @@ function (array $property) { $propertyValue = $object[$propertyName]; - // Handle array properties. + // Handle array properties. No emptiness re-check: the loop head above + // already skipped every empty $object[$propertyName]. if ($definition['type'] === 'array' && isset($definition['items']['inversedBy']) === true) { - if (is_array($propertyValue) === true && empty($propertyValue) === false) { - $createdUuids = []; - foreach ($propertyValue as $item) { - if (is_array($item) === true && $this->utilityHandler->isUuid($item) === false) { - // This is a nested object, create it first. - $createdUuid = $this->createRelatedObject( - objectData: $item, - definition: $definition['items'], - parentUuid: $uuid, - currentRegister: $currentRegister - ); + $createdUuids = []; + foreach ($propertyValue as $item) { + if (is_array($item) === true && $this->utilityHandler->isUuid($item) === false) { + // This is a nested object, create it first. + $createdUuid = $this->createRelatedObject( + objectData: $item, + definition: $definition['items'], + parentUuid: $uuid, + currentRegister: $currentRegister + ); - // If creation failed, keep original item to avoid empty array. - $createdUuids[] = $createdUuid ?? $item; - } elseif (is_string($item) === true && $this->utilityHandler->isUuid($item) === true) { - // This is already a UUID, keep it. - $createdUuids[] = $item; - } + // If creation failed, keep original item to avoid empty array. + $createdUuids[] = $createdUuid ?? $item; + } elseif (is_string($item) === true && $this->utilityHandler->isUuid($item) === true) { + // This is already a UUID, keep it. + $createdUuids[] = $item; } + } - $object[$propertyName] = $createdUuids; - }//end if + $object[$propertyName] = $createdUuids; } elseif (isset($definition['inversedBy']) === true && $definition['type'] !== 'array') { // Handle single object properties. if (is_array($propertyValue) === true && $this->utilityHandler->isUuid($propertyValue) === false) { diff --git a/lib/Service/Object/DeleteObject.php b/lib/Service/Object/DeleteObject.php index 919ce17c4d..4858f9b6d6 100644 --- a/lib/Service/Object/DeleteObject.php +++ b/lib/Service/Object/DeleteObject.php @@ -339,50 +339,56 @@ public function delete( // Update the object in database (soft delete - keeps record with deleted metadata). // Pass register/schema context for magic mapper routing. // @psalm-suppress InvalidArgument - ObjectEntity extends Entity. - $result = $this->objectEntityMapper->update( + // update() is declared non-nullable and throws on failure, so the old + // `!== null` test and the `if ($result === true)` guard below it were + // both always true: reaching the next line IS the success case. + $this->objectEntityMapper->update( entity: $objectEntity, register: $registerEntity, schema: $schemaEntity, oldEntity: $preDeleteState - ) !== null; + ); + $result = true; \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.update'); - // **CACHE INVALIDATION**: Clear collection and facet caches so soft-deleted objects disappear from regular queries. - if ($result === true) { - /* - * ObjectEntity has getRegister() and getSchema() methods that return string|null. - * Convert to int|null for invalidateForObjectChange which expects ?int. - * @var ObjectEntity $objectEntity - */ - - $registerId = $objectEntity->getRegister(); - $schemaId = $objectEntity->getSchema(); - - // Convert register ID to int if numeric. - $registerIdInt = null; - if ($registerId !== null && is_numeric($registerId) === true) { - $registerIdInt = (int)$registerId; - } + /* + * **CACHE INVALIDATION**: Clear collection and facet caches so + * soft-deleted objects disappear from regular queries. + * + * ObjectEntity has getRegister() and getSchema() methods that return + * string|null. Convert to int|null for invalidateForObjectChange which + * expects ?int. + * + * @var ObjectEntity $objectEntity + */ + + $registerId = $objectEntity->getRegister(); + $schemaId = $objectEntity->getSchema(); + + // Convert register ID to int if numeric. + $registerIdInt = null; + if ($registerId !== null && is_numeric($registerId) === true) { + $registerIdInt = (int)$registerId; + } - // Convert schema ID to int if numeric. - $schemaIdInt = null; - if ($schemaId !== null && is_numeric($schemaId) === true) { - $schemaIdInt = (int)$schemaId; - } + // Convert schema ID to int if numeric. + $schemaIdInt = null; + if ($schemaId !== null && is_numeric($schemaId) === true) { + $schemaIdInt = (int)$schemaId; + } - try { - $this->cacheHandler->invalidateForObjectChange( - object: $objectEntity, - operation: 'soft_delete', - registerId: $registerIdInt, - schemaId: $schemaIdInt - ); - } catch (\Exception $e) { - // Gracefully handle cache invalidation errors (e.g., Solr not configured). - // Soft deletion should succeed even if cache invalidation fails. - } - }//end if + try { + $this->cacheHandler->invalidateForObjectChange( + object: $objectEntity, + operation: 'soft_delete', + registerId: $registerIdInt, + schemaId: $schemaIdInt + ); + } catch (\Exception $e) { + // Gracefully handle cache invalidation errors (e.g., Solr not configured). + // Soft deletion should succeed even if cache invalidation fails. + } \OCA\OpenRegister\Service\WritePhaseProbe::stamp('del.cache'); diff --git a/lib/Service/Object/FacetHandler.php b/lib/Service/Object/FacetHandler.php index edffa13df8..3706ed6588 100644 --- a/lib/Service/Object/FacetHandler.php +++ b/lib/Service/Object/FacetHandler.php @@ -185,12 +185,10 @@ public function getFacetsForObjects(array $query = []): array { $facetConfig = [$facetConfig]; } - // Handle _facets as numerically-indexed array (e.g., _facets[]=standaardversies). - // PHP converts ?_facets[]=foo&_facets[]=bar into [0 => 'foo', 1 => 'bar']. - // Ensure it stays a simple list of field names. - if (is_array($facetConfig) === true && array_is_list($facetConfig) === true) { - $facetConfig = array_values(array: $facetConfig); - } + // No reindex step for the numerically-indexed `_facets[]=foo&_facets[]=bar` + // form: PHP already delivers that as [0 => 'foo', 1 => 'bar'], and the + // array_values() that used to sit here only ran when array_is_list() was + // already true — i.e. never did anything. // **PAGINATION INDEPENDENCE**: Remove pagination params for facet calculation. $facetQuery = $query; diff --git a/lib/Service/Object/PermissionHandler.php b/lib/Service/Object/PermissionHandler.php index be4284a09e..81b0b3439e 100644 --- a/lib/Service/Object/PermissionHandler.php +++ b/lib/Service/Object/PermissionHandler.php @@ -1211,7 +1211,9 @@ public function filterUuidsForPermissions(array $uuids, bool $_rbac, bool $_mult } }//end foreach - return array_values(array_filter($filteredUuids, fn ($uuid) => $uuid !== null)); + // No array_filter for nulls and no array_values: $filteredUuids never + // contains a null and is already a list, so both were no-ops. + return $filteredUuids; }//end filterUuidsForPermissions() /** diff --git a/lib/Service/Object/RelationHandler.php b/lib/Service/Object/RelationHandler.php index 6597890f33..8e4d88c340 100644 --- a/lib/Service/Object/RelationHandler.php +++ b/lib/Service/Object/RelationHandler.php @@ -152,7 +152,6 @@ function (ObjectEntity $object) use ($property, $key) { $serialized = $object->jsonSerialize(); $idRaw = null; if (is_array($property) === true - && is_array($serialized) === true && isset($property['inversedBy']) === true ) { $idRaw = $serialized[$property['inversedBy']]; @@ -849,10 +848,15 @@ private function filterByRbac(array $objects): array { * * @return (array|int|mixed|string)[] Paginated results with referencing objects. * - * @psalm-return array{results: array, total: 0, - * limit: 30|mixed, offset: 0|mixed, + * @psalm-return array{results: list>, total: int<0, max>, + * limit: mixed, offset: mixed, * message?: 'Reverse relationship lookup not yet implemented'} * + * The previous annotation said `results: array, total: 0` — + * inferred back when this method was a stub that returned nothing and the + * "not yet implemented" message. It has since been implemented and returns + * real rows; only the `message` key is still optional/legacy. + * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) RBAC/multitenancy flags follow established API patterns * * @spec openspec/specs/linked-entity-types/spec.md diff --git a/lib/Service/Object/RenderObject.php b/lib/Service/Object/RenderObject.php index deaa05197a..60897c2e81 100644 --- a/lib/Service/Object/RenderObject.php +++ b/lib/Service/Object/RenderObject.php @@ -1011,6 +1011,16 @@ private function renderFiles(ObjectEntity $object): ObjectEntity { // Batch-load all file tags in 2 queries instead of 2*N queries. $allFileIds = array_map(fn ($f) => (string)$f['fileid'], $fileRecords); + + // OCP declares this `array>`, but file ids are + // canonical numeric strings, so PHP coerces every key to INT on the way + // in. Without the annotation below PHPStan concludes a numeric-string + // offset can never match an `array` and reports the + // per-file tag loop as iterating an always-empty array. It is not: PHP + // applies the same coercion on lookup, so `$allTagIdsPerFile["123"]` + // finds the row stored under 123. + // phpcs:ignore Squiz.Commenting.InlineComment.DocBlock -- PHPStan only reads @var from a /** */ block. + /** @var array> $allTagIdsPerFile */ $allTagIdsPerFile = $this->systemTagMapper->getTagIdsForObjects( objIds: $allFileIds, objectType: 'files' @@ -4091,6 +4101,11 @@ private function batchPreloadFileObjects(array $entities): void { // 3) Batch-load all tag ids for all files in one query. $stringFileIds = array_map(static fn ($id) => (string)$id, $fileIds); + + // Numeric-string keys are coerced to int by PHP — see the longer + // note on the other getTagIdsForObjects() call in this class. + // phpcs:ignore Squiz.Commenting.InlineComment.DocBlock -- PHPStan only reads @var from a /** */ block. + /** @var array> $allTagIdsPerFile */ $allTagIdsPerFile = $this->systemTagMapper->getTagIdsForObjects( objIds: $stringFileIds, objectType: 'files' diff --git a/lib/Service/Object/SaveObject.php b/lib/Service/Object/SaveObject.php index cfbdf7bd92..e3155635e1 100644 --- a/lib/Service/Object/SaveObject.php +++ b/lib/Service/Object/SaveObject.php @@ -537,13 +537,13 @@ private function resolveSchemaReference(string $reference): ?string { // Try direct slug match as last resort. try { // SchemaMapper->find() supports id, uuid, and slug via orX(). + // find() throws when there is no match; the catch below is the + // "not found" path, so no null test is needed here. $schema = $this->schemaMapper->find(id: $slug, _rbac: false, _multitenancy: false); - if ($schema !== null) { - $schemaId = (string)$schema->getId(); - $this->schemaCache[$schemaId] = $schema; - $this->schemaReferenceCache[$reference] = $schemaId; - return $schemaId; - } + $schemaId = (string)$schema->getId(); + $this->schemaCache[$schemaId] = $schema; + $this->schemaReferenceCache[$reference] = $schemaId; + return $schemaId; } catch (Exception $e) { // Schema not found. } @@ -2838,9 +2838,9 @@ public function saveObject( _rbac: false, _multitenancy: false ); - if ($tempExistingObject !== null) { - $existingObjectData = $tempExistingObject->getObject(); - } + // The find() call throws DoesNotExistException rather than returning + // null — that catch below is the real "missing" path. + $existingObjectData = $tempExistingObject->getObject(); } catch (DoesNotExistException $e) { // Object doesn't exist, treat as create. $isCreate = true; @@ -3108,8 +3108,9 @@ private function resolveSchemaAndRegister( // Use cached schema lookup instead of direct mapper call. $schema = $this->getCachedSchema(schemaId: $schemaId); - } elseif (is_int($schema) === true) { - // It's an integer ID - use cached lookup. + } else { + // Only the integer-ID case is left after the Schema and string arms + // above, so no is_int() re-test — use the cached lookup. $schemaId = $schema; $schema = $this->getCachedSchema(schemaId: $schema); } @@ -5687,8 +5688,9 @@ private function isValueNotEmpty($value): bool { return false; } - // For objects/arrays with content, check recursively. - if (is_array($value) === true && empty($value) === false) { + // For objects/arrays with content, check recursively. The empty-array + // case already returned above, so only the array-ness matters here. + if (is_array($value) === true) { // If it's an associative array (object-like), check if it's effectively empty. if (array_keys($value) !== range(0, count($value) - 1)) { return $this->isEffectivelyEmptyObject(object: $value) === false; diff --git a/lib/Service/Object/SaveObject/FilePropertyHandler.php b/lib/Service/Object/SaveObject/FilePropertyHandler.php index e140447730..9ac75c9279 100644 --- a/lib/Service/Object/SaveObject/FilePropertyHandler.php +++ b/lib/Service/Object/SaveObject/FilePropertyHandler.php @@ -620,9 +620,7 @@ public function handleFileProperty( fileConfig: $fileConfig, index: $index ); - if ($fileId !== null) { - $fileIds[] = $fileId; - } + $fileIds[] = $fileId; } } @@ -641,9 +639,7 @@ public function handleFileProperty( ); // Replace the file content with file ID in the object data. - if ($fileId !== null) { - $object[$propertyName] = $fileId; - } + $object[$propertyName] = $fileId; } }//end if }//end handleFileProperty() diff --git a/lib/Service/Object/TranslationHandler.php b/lib/Service/Object/TranslationHandler.php index 4c08a20b4e..af8cafbc30 100644 --- a/lib/Service/Object/TranslationHandler.php +++ b/lib/Service/Object/TranslationHandler.php @@ -266,15 +266,14 @@ public function normalizeTranslationsForSave( continue; }//end if - // Simple value path. - if ($value !== null) { - if ($targetLanguage !== null && $targetLanguage !== '') { - // Shape B — wrap under the target language. - $objectData[$propName] = [$targetLanguage => $value]; - } else { - // Shape A — legacy: wrap under register default. - $objectData[$propName] = [$defaultLanguage => $value]; - } + // Simple value path. $value is non-null by this point — the null + // case is handled by the continue above. + if ($targetLanguage !== null && $targetLanguage !== '') { + // Shape B — wrap under the target language. + $objectData[$propName] = [$targetLanguage => $value]; + } else { + // Shape A — legacy: wrap under register default. + $objectData[$propName] = [$defaultLanguage => $value]; } }//end foreach diff --git a/lib/Service/Object/ValidateObject.php b/lib/Service/Object/ValidateObject.php index 7c0d8e9d84..caf41b1c3a 100644 --- a/lib/Service/Object/ValidateObject.php +++ b/lib/Service/Object/ValidateObject.php @@ -1488,10 +1488,9 @@ private function isSelfReference(object $propertySchema, string $schemaSlug): bo private function findSchemaBySlug(string $slug): ?Schema { try { // Try direct slug match first using the find method which supports slug lookups. - $schema = $this->schemaMapper->find($slug); - if ($schema !== null) { - return $schema; - } + // find() throws when there is no match; the catch below is the + // "not found" path, so no null test is needed here. + return $this->schemaMapper->find($slug); } catch (Exception $e) { // Continue with case-insensitive search. } diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 9b500b5c4e..49dc19d624 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -1297,7 +1297,7 @@ public function saveObject( // If UUID was null and is now set, mark it as auto-generated in object data. // This allows SaveObject to distinguish between user-provided UUIDs (UPDATE) // and auto-generated UUIDs (CREATE). - if ($uuidWasNull === true && $uuid !== null && is_array($object) === true) { + if ($uuidWasNull === true && $uuid !== null) { // Store flag in @self to indicate this is a CREATE operation. if (isset($object['@self']) === false || is_array($object['@self']) === false) { $object['@self'] = []; @@ -1347,7 +1347,7 @@ public function saveObject( // carry a UUID); the seed itself is empty-field-only and fail-soft, so // it never overwrites a client-supplied value. See the object-lifecycle // spec: fk-graph-lifecycle-transitions. - if ($uuidWasNull === true && is_array($object) === true) { + if ($uuidWasNull === true) { $object = $this->saveHandler->seedLifecycleFieldOnCreate( schema: $this->currentSchema, data: $object @@ -3491,10 +3491,9 @@ private function collectUuidsFromArrayResult(array $resultData, array &$uuids): } }//end if - // Collect UUIDs from object properties. - if (is_array($objectData) === true) { - $this->collectUuidsFromObjectData(data: $objectData, uuids: $uuids); - } + // Collect UUIDs from object properties. $objectData is always an array + // by this point, so the old is_array() guard could never skip the call. + $this->collectUuidsFromObjectData(data: $objectData, uuids: $uuids); }//end collectUuidsFromArrayResult() /** diff --git a/lib/Service/Quality/QualityStatisticsService.php b/lib/Service/Quality/QualityStatisticsService.php index 5c0ac5aede..3a9400737a 100644 --- a/lib/Service/Quality/QualityStatisticsService.php +++ b/lib/Service/Quality/QualityStatisticsService.php @@ -340,10 +340,8 @@ private function loadAnnotation($schema): array { return []; } - if ($entity 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 = ($entity->getConfiguration() ?? []); $annotation = ($config['x-openregister-quality'] ?? null); if (is_array($annotation) === true) { diff --git a/lib/Service/Reporting/SpreadsheetReportWriter.php b/lib/Service/Reporting/SpreadsheetReportWriter.php index cfdce19446..50b5b66a7c 100644 --- a/lib/Service/Reporting/SpreadsheetReportWriter.php +++ b/lib/Service/Reporting/SpreadsheetReportWriter.php @@ -260,7 +260,7 @@ private function headlineFor(array $widget, ?array $data): string { } $valueField = $widget['options']['valueField'] ?? 'value'; - if (isset($data[$valueField]) === true && $data[$valueField] !== null) { + if (isset($data[$valueField]) === true) { return (string)$data[$valueField]; } diff --git a/lib/Service/Schema/SchemaDiffService.php b/lib/Service/Schema/SchemaDiffService.php index 525bbc579f..699828f323 100644 --- a/lib/Service/Schema/SchemaDiffService.php +++ b/lib/Service/Schema/SchemaDiffService.php @@ -306,7 +306,10 @@ private function diffProperty(string $name, array $oldDef, array $newDef): array continue; } - if ($hadOld === true && $hasNew === false) { + // Only the $hadOld && !$hasNew combination is left: the equal case and + // the !$hadOld && $hasNew case both continue above, so $hadOld is + // already known true here. + if ($hasNew === false) { // Removed bound = relaxation. $changes[] = [ 'property' => $name, diff --git a/lib/Service/SchemaService.php b/lib/Service/SchemaService.php index 2119d59b6d..1c02d7c82f 100644 --- a/lib/Service/SchemaService.php +++ b/lib/Service/SchemaService.php @@ -546,7 +546,7 @@ private function mergePropertyAnalysis(array &$existingAnalysis, array $newAnaly } // Merge detected formats (if consistent patterns emerge). - if (($newAnalysis['detected_format'] ?? null) !== null && ($newAnalysis['detected_format'] !== null) === true) { + if (($newAnalysis['detected_format'] ?? null) !== null) { $existingAnalysis['detected_format'] = $this->consolidateFormatDetection( existingFormat: $existingAnalysis['detected_format'] ?? null, newFormat: $newAnalysis['detected_format'] @@ -1163,7 +1163,6 @@ private function compareStringConstraints(array $currentConfig, array $analysis, // Check for missing format. if (($analysis['detected_format'] ?? null) !== null - && ($analysis['detected_format'] !== null) === true && ($analysis['detected_format'] !== '') === true ) { $currentFormat = $currentConfig['format'] ?? null; diff --git a/lib/Service/SearchTrailService.php b/lib/Service/SearchTrailService.php index ec3cbfeeba..72381efaec 100644 --- a/lib/Service/SearchTrailService.php +++ b/lib/Service/SearchTrailService.php @@ -666,11 +666,10 @@ private function processConfig(array $config): array { ]; foreach ($config as $key => $value) { - // Ensure key is a string or integer to avoid "Illegal offset type" error. - if (is_string($key) === true || is_int($key) === true) { - if (in_array($key, $excludeKeys, true) === false && str_starts_with((string)$key, '_') === false) { - $processed['filters'][$key] = $value; - } + // No string-or-integer guard: an array key is always one of those, + // so it could never reject a key. + if (in_array($key, $excludeKeys, true) === false && str_starts_with((string)$key, '_') === false) { + $processed['filters'][$key] = $value; } } @@ -807,7 +806,9 @@ private function parseUserAgent(string $userAgent): array { if (preg_match($pattern, $userAgent, $matches) === 1) { return [ 'browser' => $browser, - 'version' => $matches[1] ?? 'unknown', + // Every pattern above has exactly one capturing group and we + // only get here when preg_match returned 1, so group 1 is set. + 'version' => $matches[1], 'full_string' => $userAgent, ]; } diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index 4aaa4db80c..8c791118eb 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -1131,7 +1131,7 @@ private function processJobsSerial( // Schema|string|int|null $schema, ?string $uuid, ...). $objectData = $object->getObject(); // Get the object business data. - $savedObject = $objectService->saveObject( + $objectService->saveObject( object: $objectData, extend: [], register: $object->getRegister(), @@ -1141,22 +1141,13 @@ private function processJobsSerial( uuid: $object->getUuid() ); - if ($savedObject !== null) { - $batchSuccesses++; - $results['stats']['successful_saves']++; - } - - if ($savedObject === null) { - $results['stats']['failed_saves']++; - $batchErrors[] = [ - 'object_id' => $object->getUuid(), - 'object_name' => $object->getName() ?? $object->getUuid(), - 'register' => $object->getRegister(), - 'schema' => $object->getSchema(), - 'error' => 'Save operation returned null', - 'batch_mode' => 'serial_optimized', - ]; - } + // The saveObject() call is declared non-nullable and signals failure by + // throwing, so reaching this line IS the success case. The + // "Save operation returned null" branch that used to sit here + // could never run; the catch below is the real failure path + // and already counts it. + $batchSuccesses++; + $results['stats']['successful_saves']++; } catch (Exception $e) { $results['stats']['failed_saves']++; $batchErrors[] = [ @@ -1353,7 +1344,7 @@ private function processBatchDirectly( // Schema|string|int|null $schema, ?string $uuid, ...). $objectData = $object->getObject(); // Get the object business data. - $savedObject = $objectService->saveObject( + $objectService->saveObject( object: $objectData, extend: [], register: $object->getRegister(), @@ -1363,20 +1354,10 @@ private function processBatchDirectly( uuid: $object->getUuid() ); - if ($savedObject !== null) { - $batchSuccesses++; - } - - if ($savedObject === null) { - $batchErrors[] = [ - 'object_id' => $object->getUuid(), - 'object_name' => $object->getName() ?? $object->getUuid(), - 'register' => $object->getRegister(), - 'schema' => $object->getSchema(), - 'error' => 'Save operation returned null', - 'batch_mode' => 'parallel_optimized', - ]; - } + // See the serial path above: saveObject() cannot return null, so + // reaching this line IS the success case and the "returned null" + // branch was dead. The catch below is the real failure path. + $batchSuccesses++; } catch (Exception $e) { $batchErrors[] = [ 'object_id' => $object->getUuid(), diff --git a/lib/Service/ShareLinkService.php b/lib/Service/ShareLinkService.php index 140930dc03..419e94a2a2 100644 --- a/lib/Service/ShareLinkService.php +++ b/lib/Service/ShareLinkService.php @@ -577,13 +577,11 @@ private function normaliseShare(IShare $share, string $userId): ?array { } $owner = (string)($share->getSharedBy() ?? ''); + // IShare::getNode() is declared to return Node and throws when the + // node is gone, so the null guard here was unreachable. $node = $share->getNode(); - $fileName = ''; - $fileId = 0; - if ($node !== null) { - $fileName = (string)$node->getName(); - $fileId = (int)$node->getId(); - } + $fileName = (string)$node->getName(); + $fileId = (int)$node->getId(); return [ 'shareId' => $shareId, diff --git a/lib/Service/SharedSchema/SchemaAttribution.php b/lib/Service/SharedSchema/SchemaAttribution.php index 470692d26d..221b3d8430 100644 --- a/lib/Service/SharedSchema/SchemaAttribution.php +++ b/lib/Service/SharedSchema/SchemaAttribution.php @@ -287,7 +287,9 @@ public function replaceSchemaId(array $schemas, int $oldId, int $newId): array { $result[] = $entry; } - return array_values($result); + // No array_values(): $result is only ever appended to, so it is already + // a list. + return $result; }//end replaceSchemaId() /** diff --git a/lib/Service/Sharing/SharePrincipalDeriver.php b/lib/Service/Sharing/SharePrincipalDeriver.php index 1694b48edb..27fd800f28 100644 --- a/lib/Service/Sharing/SharePrincipalDeriver.php +++ b/lib/Service/Sharing/SharePrincipalDeriver.php @@ -179,7 +179,12 @@ public function validEntries(mixed $sharedWith): array { * @param mixed $sharedWith The raw `sharedWith` value. * @param string $userId The acting user id. * @param string[] $userGroups The acting user's group ids. - * @param string[] $permissions Permissions that satisfy the check. + * @param array $permissions Permissions that satisfy the check. + * null is a MEANINGFUL member, not sloppiness: a share entry that + * carries no explicit permission has `permission => null`, and both + * callers pass `['use', null]` to mean "an unqualified share counts + * as 'use'". Narrowing this to string[] would silently stop matching + * those entries. * * @return bool True when a matching principal holds one of the permissions. */ diff --git a/lib/Service/Sync/HarvestPipelineService.php b/lib/Service/Sync/HarvestPipelineService.php index 0279da2e0e..fa33c75c7c 100644 --- a/lib/Service/Sync/HarvestPipelineService.php +++ b/lib/Service/Sync/HarvestPipelineService.php @@ -235,7 +235,15 @@ public function import(Source $source, array $records, string $executionId): arr * @param Source $source The source * @param SyncRecord $record The fetched record * @param Mapping|null $mapping The configured mapping (or null for pass-through) - * @param array $summary Running summary (by reference) + * @param array $summary Running summary (by reference) + * + * The precise shape lives in @phpstan-param below rather than inline: every + * counter is an int, which is what stops `$summary['created']++` reading as + * a string increment. It does not fit on one @param line under the 150-char + * limit, and wrapping a @param breaks the parameter-name sniff. + * + * @phpstan-param array{executionId: string, gathered: int, created: int, + * updated: int, unchanged: int, conflicts: int, errors: int} $summary * * @return void * diff --git a/lib/Service/TextExtraction/EntityRecognitionHandler.php b/lib/Service/TextExtraction/EntityRecognitionHandler.php index fe89967b2a..5d2b34daa9 100644 --- a/lib/Service/TextExtraction/EntityRecognitionHandler.php +++ b/lib/Service/TextExtraction/EntityRecognitionHandler.php @@ -1063,10 +1063,8 @@ private function sanitizeUtf8(string $value): string { } if (function_exists('mb_scrub') === true) { - $scrubbed = mb_scrub($value, 'UTF-8'); - if (is_string($scrubbed) === true) { - return $scrubbed; - } + // The mb_scrub() call returns string, so no is_string() re-check. + return mb_scrub($value, 'UTF-8'); } $converted = @iconv('UTF-8', 'UTF-8//IGNORE', $value); diff --git a/lib/Service/TextExtraction/ObjectHandler.php b/lib/Service/TextExtraction/ObjectHandler.php index 664e47e33b..93cdd382c6 100644 --- a/lib/Service/TextExtraction/ObjectHandler.php +++ b/lib/Service/TextExtraction/ObjectHandler.php @@ -310,12 +310,12 @@ private function extractTextFromArray(array $data, string $prefix = '', int $dep foreach ($data as $key => $value) { // Build context path. $contextKey = (string)$key; - if ($prefix !== null && $prefix !== '') { + if ($prefix !== '') { $contextKey = "{$prefix}.{$key}"; } // Handle different value types. - if (is_string($value) === true && trim($value) !== '' && trim($value) !== null) { + if (is_string($value) === true && trim($value) !== '') { $textParts[] = "{$contextKey}: {$value}"; } elseif (is_numeric($value) === true) { $textParts[] = "{$contextKey}: {$value}"; diff --git a/lib/Service/TextExtractionService.php b/lib/Service/TextExtractionService.php index 438efb9df3..647cf44705 100644 --- a/lib/Service/TextExtractionService.php +++ b/lib/Service/TextExtractionService.php @@ -1632,7 +1632,7 @@ private function chunkFixedSize(string $text, int $chunkSize, int $chunkOverlap) $chunks, function ($chunk) { $trimmed = trim($chunk['text']); - return $trimmed !== '' && $trimmed !== null; + return $trimmed !== ''; } ); }//end chunkFixedSize() @@ -1812,7 +1812,7 @@ private function recursiveSplit(string $text, array $separators, int $chunkSize, $chunks, function ($chunk) { $trimmed = trim($chunk['text']); - return $trimmed !== '' && $trimmed !== null; + return $trimmed !== ''; } ); }//end recursiveSplit() diff --git a/lib/Service/TimeTrackerLinkService.php b/lib/Service/TimeTrackerLinkService.php index 50cf34ad0b..73c6ed351c 100644 --- a/lib/Service/TimeTrackerLinkService.php +++ b/lib/Service/TimeTrackerLinkService.php @@ -932,7 +932,7 @@ private function normaliseRow(object $row): array { } $duration = null; - if (isset($row->duration) === true && $row->duration !== '' && $row->duration !== null) { + if (isset($row->duration) === true && $row->duration !== '') { $duration = (int)$row->duration; } diff --git a/lib/Service/TmloService.php b/lib/Service/TmloService.php index 966ad4b7cc..1ecc0c7225 100644 --- a/lib/Service/TmloService.php +++ b/lib/Service/TmloService.php @@ -267,7 +267,6 @@ public function validateFieldValues(array $tmlo): array { // Validate archiefnominatie. if (isset($tmlo['archiefnominatie']) === true - && $tmlo['archiefnominatie'] !== null && in_array($tmlo['archiefnominatie'], self::VALID_ARCHIEFNOMINATIE, true) === false ) { $allowed = implode(', ', self::VALID_ARCHIEFNOMINATIE); @@ -277,7 +276,6 @@ public function validateFieldValues(array $tmlo): array { // Validate archiefstatus. if (isset($tmlo['archiefstatus']) === true - && $tmlo['archiefstatus'] !== null && in_array($tmlo['archiefstatus'], self::VALID_ARCHIEFSTATUS, true) === false ) { $allowed = implode(', ', self::VALID_ARCHIEFSTATUS); @@ -286,7 +284,7 @@ public function validateFieldValues(array $tmlo): array { } // Validate bewaarTermijn as ISO-8601 duration. - if (isset($tmlo['bewaarTermijn']) === true && $tmlo['bewaarTermijn'] !== null) { + if (isset($tmlo['bewaarTermijn']) === true) { try { new DateInterval($tmlo['bewaarTermijn']); } catch (Exception $e) { @@ -296,7 +294,7 @@ public function validateFieldValues(array $tmlo): array { } // Validate archiefactiedatum as ISO-8601 date. - if (isset($tmlo['archiefactiedatum']) === true && $tmlo['archiefactiedatum'] !== null) { + if (isset($tmlo['archiefactiedatum']) === true) { $date = DateTime::createFromFormat('Y-m-d', $tmlo['archiefactiedatum']); if ($date === false || $date->format('Y-m-d') !== $tmlo['archiefactiedatum']) { $got = $tmlo['archiefactiedatum']; diff --git a/lib/Service/UserService.php b/lib/Service/UserService.php index 3c382efa10..3ed7856c66 100644 --- a/lib/Service/UserService.php +++ b/lib/Service/UserService.php @@ -31,6 +31,7 @@ use OCA\OpenRegister\Db\AuditTrailMapper; use OCA\OpenRegister\Event\UserProfileUpdatedEvent; use OCP\Accounts\IAccountManager; +use OCP\Accounts\PropertyDoesNotExistException; use OCP\EventDispatcher\IEventDispatcher; use OCP\IAvatarManager; use OCP\IConfig; @@ -196,35 +197,24 @@ public function buildUserDataArray(IUser $user): array { $avatarScope = $user->getAvatarScope(); } - $lastLogin = 0; - if (method_exists($user, 'getLastLogin') === true) { - $lastLogin = $user->getLastLogin(); - } - - $backend = 'unknown'; - if (method_exists($user, 'getBackendClassName') === true) { - $backend = $user->getBackendClassName(); - } - - $canChangeDisplayName = false; - if (method_exists($user, 'canChangeDisplayName') === true) { - $canChangeDisplayName = $user->canChangeDisplayName(); - } + // OCP\IUser declares getLastLogin(), getBackendClassName(), + // canChangeDisplayName(), canChangePassword() and canChangeAvatar(), so + // the method_exists() guards that used to wrap these could never be + // false and their defaults were unreachable. The two guards that remain + // (getAvatarScope above, canChangeMailAddress below) are NOT decoration: + // those methods are absent from the interface and only present on some + // implementations. + $lastLogin = $user->getLastLogin(); + $backend = $user->getBackendClassName(); + $canChangeDisplayName = $user->canChangeDisplayName(); $canChangeEmail = false; if (method_exists($user, 'canChangeMailAddress') === true) { $canChangeEmail = $user->canChangeMailAddress(); } - $canChangePassword = false; - if (method_exists($user, 'canChangePassword') === true) { - $canChangePassword = $user->canChangePassword(); - } - - $canChangeAvatar = false; - if (method_exists($user, 'canChangeAvatar') === true) { - $canChangeAvatar = $user->canChangeAvatar(); - } + $canChangePassword = $user->canChangePassword(); + $canChangeAvatar = $user->canChangeAvatar(); $result = [ 'uid' => $user->getUID(), @@ -478,10 +468,8 @@ public function setCustomNameFields(IUser $user, array $nameFields): void { */ private function buildQuotaInformation(IUser $user): array { try { - $userQuota = 'none'; - if (method_exists($user, 'getQuota') === true) { - $userQuota = $user->getQuota(); - } + // OCP\IUser declares getQuota(), so the 'none' default was dead. + $userQuota = $user->getQuota(); $usedSpace = 0; @@ -741,12 +729,11 @@ private function getAccountManagerPropertiesSelectively(IUser $user): array { foreach ($neededProperties as $propertyName => $apiField) { try { - $property = $account->getProperty($propertyName); - if ($property !== null) { - $value = $property->getValue(); - if (empty($value) === false) { - $additionalInfo[$apiField] = $value; - } + // No `!== null` check: getProperty() throws rather than returning + // null, and the surrounding catch already handles that. + $value = $account->getProperty($propertyName)->getValue(); + if (empty($value) === false) { + $additionalInfo[$apiField] = $value; } } catch (\Exception $e) { $this->logger->debug( @@ -774,7 +761,6 @@ private function getAccountManagerPropertiesSelectively(IUser $user): array { */ private function updateStandardUserProperties(IUser $user, array $data): void { if (isset($data['displayName']) === true - && method_exists($user, 'canChangeDisplayName') === true && $user->canChangeDisplayName() === true ) { $user->setDisplayName($data['displayName']); @@ -788,7 +774,6 @@ private function updateStandardUserProperties(IUser $user, array $data): void { } if (isset($data['password']) === true - && method_exists($user, 'canChangePassword') === true && $user->canChangePassword() === true ) { $user->setPassword($data['password']); @@ -838,7 +823,14 @@ private function updateProfileProperties(IUser $user, array $data): void { $value = (string)$data[$apiField]; - if ($account->getProperty($accountProperty) !== null) { + // IAccount::getProperty() THROWS PropertyDoesNotExistException when + // the property is absent — it never returns null. The `!== null` + // test that used to guard this block was therefore always true, + // which made the create-it fallback below UNREACHABLE: an unknown + // property escaped to the outer catch and abandoned the whole + // account update with a warning instead of being created. Catching + // the exception here is what actually reaches that fallback. + try { $property = $account->getProperty($accountProperty); if ($property->getValue() !== $value) { $property->setValue($value); @@ -846,6 +838,8 @@ private function updateProfileProperties(IUser $user, array $data): void { } continue; + } catch (PropertyDoesNotExistException $e) { + // Fall through to the create path below. } // Property doesn't exist, create it. @@ -936,7 +930,7 @@ private function getDefaultPropertyScope(string $propertyName): string { */ public function changePassword(IUser $user, string $currentPassword, string $newPassword): array { // Check backend capability. - if (method_exists($user, 'canChangePassword') === true && $user->canChangePassword() === false) { + if ($user->canChangePassword() === false) { throw new RuntimeException( 'Password changes are not supported by your authentication backend', 409 @@ -982,7 +976,7 @@ public function changePassword(IUser $user, string $currentPassword, string $new */ public function uploadAvatar(IUser $user, string $data, string $mimeType, int $size): array { // Check backend capability. - if (method_exists($user, 'canChangeAvatar') === true && $user->canChangeAvatar() === false) { + if ($user->canChangeAvatar() === false) { throw new RuntimeException( 'Avatar changes are not supported by your authentication backend', 409 @@ -1027,7 +1021,7 @@ public function uploadAvatar(IUser $user, string $data, string $mimeType, int $s */ public function deleteAvatar(IUser $user): array { // Check backend capability. - if (method_exists($user, 'canChangeAvatar') === true && $user->canChangeAvatar() === false) { + if ($user->canChangeAvatar() === false) { throw new RuntimeException( 'Avatar changes are not supported by your authentication backend', 409 diff --git a/lib/Service/Vectorization/Handlers/EmbeddingGeneratorHandler.php b/lib/Service/Vectorization/Handlers/EmbeddingGeneratorHandler.php index 08d4aa459f..edf8e901b2 100644 --- a/lib/Service/Vectorization/Handlers/EmbeddingGeneratorHandler.php +++ b/lib/Service/Vectorization/Handlers/EmbeddingGeneratorHandler.php @@ -290,7 +290,7 @@ public function embedText(string $text): array { $error = curl_error($ch); // No curl_close(): deprecated since PHP 8.0 and a no-op — the // CurlHandle object is freed when it goes out of scope. - if ($error !== null && $error !== '') { + if ($error !== '') { throw new Exception("Fireworks API request failed: {$error}"); } diff --git a/lib/Service/Vectorization/Strategies/ObjectVectorizationStrategy.php b/lib/Service/Vectorization/Strategies/ObjectVectorizationStrategy.php index 1b49a3f846..dc2bc6ddcb 100644 --- a/lib/Service/Vectorization/Strategies/ObjectVectorizationStrategy.php +++ b/lib/Service/Vectorization/Strategies/ObjectVectorizationStrategy.php @@ -244,8 +244,9 @@ public function prepareVectorMetadata($entity, array $item): array { $description = $objectData['beschrijving'] ?? $objectData['summary'] ?? $objectData['_summary'] ?? ''; } - // Extract @self keys for logging. - $this->extractSelfKeys(objectData: $objectData); + // No "extract @self keys for logging" call here: extractSelfKeys() is + // pure, nothing consumed its result, and the metadata builder above + // already calls it for the '@self_keys' field that is actually stored. // Extract register and schema IDs from multiple possible locations. $selfData = $objectData['@self'] ?? []; diff --git a/lib/Service/VectorizationService.php b/lib/Service/VectorizationService.php index 9e1df59d8b..d6218e27c6 100644 --- a/lib/Service/VectorizationService.php +++ b/lib/Service/VectorizationService.php @@ -304,8 +304,7 @@ private function vectorizeEntity($entity, VectorizationStrategyInterface $strate $embeddingData = $embeddings[$index] ?? null; $hasEmbedding = $embeddingData !== null - && (($embeddingData['embedding'] ?? null) !== null) - && $embeddingData['embedding'] !== null; + && (($embeddingData['embedding'] ?? null) !== null); if ($hasEmbedding === true) { $this->storeVector( entity: $entity, diff --git a/lib/Service/ViewPresentationService.php b/lib/Service/ViewPresentationService.php index 6795502ba0..6b5307c0a4 100644 --- a/lib/Service/ViewPresentationService.php +++ b/lib/Service/ViewPresentationService.php @@ -383,7 +383,7 @@ private function objectIdentity(mixed $object): string { } } - if (property_exists($object, 'id') === true && $object->getId() !== null) { + if ($object->getId() !== null) { return (string)$object->getId(); } } diff --git a/lib/Tool/AbstractTool.php b/lib/Tool/AbstractTool.php index ad50d7cb97..6b41c8ce99 100644 --- a/lib/Tool/AbstractTool.php +++ b/lib/Tool/AbstractTool.php @@ -388,7 +388,7 @@ public function __call(string $name, array $arguments) { // Step 5c: Type-cast argument to match method signature. // This ensures type safety when LLM provides loosely-typed values. $type = $param->getType(); - if ($type !== null && $type instanceof \ReflectionNamedType) { + if ($type instanceof \ReflectionNamedType) { $typeName = $type->getName(); if ($typeName === 'int') { diff --git a/phpstan.neon b/phpstan.neon index 2f227b586e..e092782b14 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -74,3 +74,97 @@ parameters: - '#should return [^ ]+ but returns ddn\\sapp\\(obj|value|pdfentry|buffer|bytes|str)\b#' - '#Parameter \$pdf_object of method ddn\\sapp\\PDFDoc::add_object\(\) expects ddn\\sapp\\PDFObject, ddn\\sapp\\obj given#' - '#Strict comparison using === between ddn\\sapp\\(obj|value|pdfentry) and false will always evaluate to false#' + # MultiTenancyTrait guards every log call with `isset($this->logger)` + # because a TRAIT cannot require a property — PHP has no abstract + # properties. Both classes using it today (SchemaMapper, MappingMapper) + # declare a non-nullable `LoggerInterface $logger`, so PHPStan resolves + # the guard per-using-class and calls it always-true (16 reports, 8 per + # class). + # + # The guard is NOT redundant in the trait's own terms: a future class + # using MultiTenancyTrait without declaring $logger would hit an + # undefined property, and isset() is what keeps that a skipped log line + # rather than a fatal. Scoped to the trait so it cannot mask the same + # shape anywhere else. + - + message: '#Property .+::\$logger \(Psr\\Log\\LoggerInterface\) in isset\(\) is not nullable#' + identifier: isset.property + path: lib/Db/MultiTenancyTrait.php + + # Same shape as the isset() guard above, reached through `?? null` + # instead of isset(): the trait cannot require the property, both using + # classes declare it non-nullable, so PHPStan resolves the coalesce + # per-using-class and calls the left side always-set. + - + message: '#Property .+::\$logger \(Psr\\Log\\LoggerInterface\) on left side of \?\? is not nullable#' + identifier: nullCoalesce.property + path: lib/Db/MultiTenancyTrait.php + + # FilesSidebarListener is registered for OCA\Files\Event\LoadAdditional- + # ScriptsEvent and compares get_class($event) against that class-string + # DELIBERATELY, so the app carries no hard dependency on the Files app + # (see the comment at the call site). The class ships with an optional + # Nextcloud app and is therefore absent during analysis, so PHPStan + # narrows get_class() to class-string, concludes the comparison + # can never match, and then reports everything after the early return as + # unreachable. + # + # At runtime the comparison DOES match — the listener only ever fires for + # that event. Replacing the string compare with an instanceof would be a + # compile-time reference to the absent class, i.e. exactly the dependency + # the string compare exists to avoid. Scoped to this one file. + - + message: '#Strict comparison using !== between class-string and .OCA\\\\Files\\\\Event\\\\LoadAdditionalScriptsEvent. will always evaluate to true#' + identifier: notIdentical.alwaysTrue + path: lib/Listener/FilesSidebarListener.php + - + message: '#Unreachable statement - code above always terminates#' + identifier: deadCode.unreachable + path: lib/Listener/FilesSidebarListener.php + + # FlowNodePreflight catches BOTH UnexpectedValueException and + # InvalidArgumentException from a third-party node's validateConfig(). + # The docblock at the call site sets out why: either is a defensible + # spelling of "this argument is wrong", and third-party nodes + # (openconnector, hermiq) pick whichever they like. Those node classes + # are not on the analysis path, so PHPStan only sees the in-tree nodes + # and concludes InvalidArgumentException is never thrown. Dropping the + # arm would reclassify a third-party node's REFUSAL as a crash. + - + message: '#Dead catch - InvalidArgumentException is never thrown in the try block#' + identifier: catch.neverThrown + path: lib/Service/Flow/FlowNodePreflight.php + + # ConfigurationMapper::invalidateConfigurationCache() is an INTENTIONAL + # no-op: organisation lookup was pulled out of the mapper (services do + # not belong in mappers) and invalidation now happens in the service + # layer. The method and its three call sites are kept so the seam stays + # visible; PHPStan correctly observes the body has no side effects. + # Delete the method, its calls AND this ignore together if the seam is + # ever removed for good. + - + message: '#Method .+ConfigurationMapper::invalidateConfigurationCache\(\) returns void but does not have any side effects#' + identifier: void.pure + path: lib/Db/ConfigurationMapper.php + + # SystemEntityObjectAdapter is a VIRTUAL ObjectEntity: it is only ever + # built by `new SystemEntityObjectAdapter(entity:, systemSlug:)` in + # SystemEntityNotificationListener, never hydrated from a database row. + # PHPStan flags its required constructor parameters because the parent + # (OCP Entity, via ObjectEntity) exposes fromRow()/fromParams() static + # factories that do `new static()` — a path this subclass never takes. + # Giving the parameters defaults would let a caller build a meaningless + # adapter with no entity and no slug, which is worse than the warning. + - + message: '#Parameter \#[12] \$(entity|systemSlug) of method .+SystemEntityObjectAdapter::__construct\(\) is not optional#' + identifier: parameter.notOptional + path: lib/Service/Notification/SystemEntityObjectAdapter.php + + # OCP types IDBConnection::executeQuery()'s $params as `string[]`, but + # bound parameters are legitimately int/float/null too — the narrow stub + # does not describe the real contract. Belongs in the shared base the + # moment a second fleet app hits it; openregister is the only one today. + - + message: '#Parameter \#2 \$params of method OCP\\IDBConnection::executeQuery\(\) expects array#' + identifier: argument.type + path: lib/Service/Vectorization/Handlers/VectorSearchHandler.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 04319bbd75..aab12e6415 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -94,11 +94,6 @@ - - - id) === true]]> - - getOrganisation() === null && isset($this->appConfig) === true]]> @@ -386,19 +381,6 @@ - - - - - - - - - - - - - calDavBackend]]> diff --git a/scripts/coverage-guard.php b/scripts/coverage-guard.php index 281de08f9e..1b27e667b8 100644 --- a/scripts/coverage-guard.php +++ b/scripts/coverage-guard.php @@ -5,6 +5,8 @@ * * Usage: * php scripts/coverage-guard.php [--against=] + * php scripts/coverage-guard.php --against= --changed-files= + * php scripts/coverage-guard.php --against= --changed-files= --deletion-neutral * php scripts/coverage-guard.php [--update-baseline] * php scripts/coverage-guard.php --capabilities * @@ -53,7 +55,16 @@ * check that did not run looking exactly like one that passed. The probe turns * that into a loud failure. */ -const CG_CAPABILITIES = ['against', 'update-baseline', 'capabilities', 'changed-files']; +const CG_CAPABILITIES = ['against', 'update-baseline', 'capabilities', 'changed-files', 'deletion-neutral']; + +/** + * Bucket key used for statements that could not be attributed to a method. + * + * It is a real key, present on both sides, so a file that falls back is compared + * exactly as it is today — conservatively, deletions included. It is never a way + * for a file to disappear from the comparison. + */ +const CG_WHOLE_FILE = ''; /** * Sum clover metrics for a named subset of files. @@ -138,6 +149,242 @@ function cgMeasureFiles(string $file, string $label, array $only): array return [$statements, $covered, $percentage]; } +/** + * Attribute every statement in the named files to the METHOD it sits in. + * + * WHY BY METHOD NAME, AND WHY NOT BY LINE NUMBER. + * + * Clover identifies a statement only by its `num` — the line it sits on — and a + * deletion shifts every line after the cut. On launchpad#128's file, 117 of the + * 254 surviving statements (46%) sit at or after the deletion site, so matching + * base statement `num=310` to head statement `num=310` compares two unrelated + * lines across half the file and returns a confident, meaningless verdict. The + * test suite measures that directly on its fixture: a line-number intersection + * there reports 182/234 head against 189/234 base, i.e. a fabricated regression, + * where method-name attribution reconciles exactly at 183/254 on both sides. + * Method names survive the shift; line numbers do not. + * + * THE SHAPE CLOVER ACTUALLY EMITS, verified against 2 608 file entries in four + * real PHPUnit artifacts from this fleet: + * + * + * + * + * + * ... + * + * + * + * `` elements are FLAT and in document order; a `type="method"` line opens + * a method and every `type="stmt"` line after it belongs to that method until the + * next one. `metrics/@statements` counts the `stmt` lines only — methods are + * counted separately and `elements` is their sum — so summing attributed + * statements reproduces the file metric wherever the line data is complete. + * + * The bucket key is the REPO-RELATIVE path that matched, never the clover + * `name`. The two reports are produced from two different checkouts and their + * absolute paths differ, so keying on `name` would put every head bucket and + * every base bucket in disjoint namespaces and the intersection would be empty — + * which reads as "nothing to compare", i.e. a pass. + * + * TWO DEGENERATE SHAPES, BOTH HANDLED FAIL-CLOSED: + * + * - A file whose metrics declare statements but which carries NO usable line + * data (the `processUncoveredFiles` shape: PHPUnit counts a file it never + * loaded). Attribution would measure it as 0/0 — an invisible pass on exactly + * the file most likely to be untested. Such a file falls back to a single + * CG_WHOLE_FILE bucket carrying its file-level metrics, the fallback is + * printed by name, and cgCollapseFiles() then puts BOTH sides on the same + * footing so the fallback cannot be mistaken for a deletion. + * - Two methods of the same name in one file (two classes in one file, in + * principle). Their statements are SUMMED into one bucket rather than + * disambiguated by ordinal, because ordinals shift when one of them is + * deleted. Summing keeps the bucket in the intersection, so deleting one of a + * same-named pair is still charged against the change. Not seen in any of the + * 2 608 entries surveyed; handled so it cannot become a silent hole. + * + * @param string $file Clover report to read. + * @param string $label Human label for error messages. + * @param array $only Repo-relative paths to include. + * + * @return array{0: array, 1: array, 2: array} + * buckets keyed "::", the repo-relative paths this report + * matched, and the paths that had to fall back to file-level metrics. + */ +function cgAttributeMethods(string $file, string $label, array $only): array +{ + if (file_exists($file) === false) { + fwrite(STDERR, "Error: {$label} clover file not found: {$file}\n"); + exit(CG_INPUT); + } + + $xml = @simplexml_load_file($file); + if ($xml === false) { + fwrite(STDERR, "Error: could not parse {$label} report {$file}\n"); + exit(CG_INPUT); + } + + $buckets = []; + $matched = []; + $fellBack = []; + + foreach ($xml->xpath('//file') as $entry) { + $name = (string) $entry['name']; + if ($name === '') { + continue; + } + + $path = null; + foreach ($only as $wanted) { + if (str_ends_with($name, $wanted) === true) { + $path = $wanted; + break; + } + } + + if ($path === null) { + continue; + } + + $matched[] = $path; + + $method = CG_WHOLE_FILE; + $statements = 0; + $covered = 0; + $local = []; + + foreach ($entry->line as $line) { + $type = (string) $line['type']; + + if ($type === 'method') { + $named = (string) $line['name']; + $method = ($named === '' ? CG_WHOLE_FILE : $named); + continue; + } + + if ($type !== 'stmt') { + continue; + } + + $key = ($path . '::' . $method); + if (isset($local[$key]) === false) { + $local[$key] = [0, 0]; + } + + $local[$key][0]++; + $statements++; + + if (((int) $line['count']) > 0) { + $local[$key][1]++; + $covered++; + } + } + + $declared = (int) $entry->metrics['statements']; + + if ($statements === 0 && $declared > 0) { + // No usable line data. Measuring 0/0 here would drop the file out of + // the comparison entirely, so fall back to the file metric and say so. + $fellBack[] = $path; + $local = [ + ($path . '::' . CG_WHOLE_FILE) => [$declared, (int) $entry->metrics['coveredstatements']], + ]; + echo " note: {$label} report carries no line data for {$path}; " + . "falling back to its file-level metric ({$entry->metrics['coveredstatements']}/{$declared}).\n"; + } else if ($statements !== $declared) { + echo " note: {$label} report declares {$declared} statement(s) for {$path} but emits " + . "{$statements} attributable line(s); the attributable ones are what is compared.\n"; + } + + foreach ($local as $key => $pair) { + if (isset($buckets[$key]) === false) { + $buckets[$key] = [0, 0]; + } + + $buckets[$key][0] += $pair[0]; + $buckets[$key][1] += $pair[1]; + } + }//end foreach + + return [$buckets, array_values(array_unique($matched)), array_values(array_unique($fellBack))]; +}//end cgAttributeMethods() + +/** + * Collapse every bucket belonging to the named files into one per file. + * + * THIS IS THE HOLE THAT WRITING THE TESTS FOUND, and it is worth naming because + * it is the invisible-pass shape in its purest form. If one side of the + * comparison cannot be attributed to methods it falls back to a single + * CG_WHOLE_FILE bucket — but that bucket's key then exists on ONE side only, so + * the asymmetric rule classifies the entire base-side file as "deleted", drops + * it, finds nothing left to compare, and reports: + * + * OK: none of the changed PHP existed at the merge base + * + * A file the guard could not read would have passed every possible drop. So when + * EITHER side falls back for a file, BOTH sides are collapsed to that file's + * single bucket: the key then exists on both sides, the file is compared exactly + * as the file-scoped mode compares it today, and the deletion penalty applies to + * it. Conservative, and visible in the output. + * + * @param array $buckets + * @param array $paths + * + * @return array + */ +function cgCollapseFiles(array $buckets, array $paths): array +{ + if (empty($paths) === true) { + return $buckets; + } + + $collapse = array_fill_keys($paths, true); + $out = []; + + foreach ($buckets as $key => $pair) { + $split = strrpos($key, '::'); + $path = ($split === false ? $key : substr($key, 0, $split)); + + if (isset($collapse[$path]) === true) { + $key = ($path . '::' . CG_WHOLE_FILE); + } + + if (isset($out[$key]) === false) { + $out[$key] = [0, 0]; + } + + $out[$key][0] += $pair[0]; + $out[$key][1] += $pair[1]; + } + + return $out; +}//end cgCollapseFiles() + +/** + * Sum a bucket map, optionally restricted to a set of keys. + * + * @param array $buckets + * @param array|null $keep Keys to include, or null for all. + * + * @return array{0:int,1:int} statements, covered + */ +function cgSumBuckets(array $buckets, ?array $keep = null): array +{ + $statements = 0; + $covered = 0; + + foreach ($buckets as $key => $pair) { + if ($keep !== null && isset($keep[$key]) === false) { + continue; + } + + $statements += $pair[0]; + $covered += $pair[1]; + } + + return [$statements, $covered]; +}//end cgSumBuckets() + /** * Read the changed-file list, keeping only PHP files the guard can measure. * @@ -299,6 +546,15 @@ function cgReport(string $label, int $statements, int $covered, float $percentag $baselineFile = (__DIR__ . '/../.coverage-baseline'); $against = ($options['against'] ?? null); $changedList = ($options['changed-files'] ?? null); +$deletionFree = isset($options['deletion-neutral']); + +// A flag that is accepted and ignored is the silent-downgrade shape this script's +// `--capabilities` probe exists to prevent, so refuse rather than fall through to +// a comparison the caller did not ask for. +if ($deletionFree === true && (is_string($changedList) === false || $changedList === '')) { + fwrite(STDERR, "Error: --deletion-neutral requires --changed-files (and therefore --against). It refines the file-scoped comparison; it has no meaning against the whole project.\n"); + exit(CG_INPUT); +} // ── scoped mode: compare ONLY the PHP the change touched ──────────────────── // @@ -321,6 +577,153 @@ function cgReport(string $label, int $statements, int $covered, float $percentag echo 'Scoped to ' . count($changed) . " changed PHP file(s).\n"; + // ── deletion-neutral mode ─────────────────────────────────────────────── + // + // WHAT THIS FIXES. `cgRatioDropped()` is a single integer cross-product with + // no tolerance, so the file-scoped ratchet demands, exactly: + // + // the code you touched must be at least as well covered as the code you + // did not. + // + // For ADDITIONS that is right, and it earns its keep: openconnector#1265 + // covered 27 of 54 new statements against a 61.86% floor, needed 34, and + // writing the missing seven is what exposed a cascade that deleted nothing + // and reported success. + // + // For DELETIONS it INVERTS. Removing `d` statements of which `c` were covered + // lowers the ratio whenever c/d exceeds the ratio of what remains — i.e. + // whenever the deleted code was better tested than average. And dead code is + // dead because nothing CALLS it, not because nothing TESTED it: gate-57 + // (orphaned-write-capability) exists to find precisely that code, so gate-57 + // and this ratchet are in arithmetic opposition, not tension. Such a pull + // request cannot satisfy the ratchet from inside its own subject at all — + // the only moves are delete less, add filler, or delete additional + // *uncovered* statements until it balances. The gate can be satisfied by + // deleting more code and cannot be satisfied by testing anything. + // + // Measured, both blocked by the file-scoped rule: + // opencatalogi#895 head 112/115 (97.39%) base 148/151 (98.01%) -0.62% + // launchpad#128 head 183/254 (72.05%) base 220/304 (72.37%) -0.32% + // + // THE RULE, AND IT IS ASYMMETRIC ON PURPOSE: + // + // drop BASE-ONLY methods (deletions) from the base side; + // KEEP HEAD-ONLY methods (additions) on the head side. + // + // The symmetric version — "compare over statements present in both reports" — + // is the obvious one and it is broken. It also drops head-only statements, so + // a change adding 40 new statements with 0 of them covered compares an empty + // set to an empty set and PASSES. That is the openconnector#1265 shape, and + // the symmetric rule would have retired the half of this ratchet that works. + // A mutant reintroducing it is in the test suite and must stay there. + // + // Consequences, all four measured in quality-config/tests/test-coverage-guard.py: + // pure deletion (opencatalogi) PASS 112/115 vs 112/115 — exactly neutral + // pure deletion (launchpad) PASS 183/254 vs 183/254 + // regression in survivors FAIL 180/254 vs 183/254 + // new untested code FAIL 183/294 (62.24%) vs 183/254 (72.05%) + // + // KNOWN RESIDUAL, stated rather than discovered: a RENAME reads as a delete + // plus an add. The old name leaves the base side, the new name arrives on the + // head side and must be covered. That is the right incentive — a renamed + // method is new code as far as the test suite is concerned — but it means a + // pure rename of a well-covered method is not free, and an author who did not + // expect it will read the failure as noise. It is documented here and in the + // failure message so it is legible when it happens. + if ($deletionFree === true) { + [$headBuckets, $headFiles, $headFellBack] = cgAttributeMethods($cloverFile, 'current', $changed); + [$baseBuckets, $baseFiles, $baseFellBack] = cgAttributeMethods($against, 'merge-base', $changed); + + // Either side falling back forces BOTH sides to file level for that file — + // see cgCollapseFiles() for why anything else is an invisible pass. + $collapse = array_values(array_unique(array_merge($headFellBack, $baseFellBack))); + if (empty($collapse) === false) { + echo 'Falling back to file-level metrics on both sides for: ' . implode(', ', $collapse) . "\n"; + $headBuckets = cgCollapseFiles($headBuckets, $collapse); + $baseBuckets = cgCollapseFiles($baseBuckets, $collapse); + } + + echo 'Deletion-neutral: attributed by method name (head ' . count($headFiles) . ' file(s), ' + . 'base ' . count($baseFiles) . ' file(s), ' . count($headBuckets) . ' head method(s), ' + . count($baseBuckets) . " base method(s)).\n"; + + // A changed file the base measured and the head did not is normally a + // DELETED file, and treating it as deleted is the point of this mode. But + // it is also what an accidental coverage exclusion looks like, and the two + // are indistinguishable from here — so it is said out loud rather than + // absorbed silently. + $goneFiles = array_values(array_diff($baseFiles, $headFiles)); + if (empty($goneFiles) === false) { + echo 'Measured at the merge base and absent from the head report: ' + . implode(', ', $goneFiles) . "\n"; + echo " Treated as deleted. If one of those files still exists, it has been dropped from\n"; + echo " coverage measurement and that is the thing to fix, not this guard.\n"; + } + + if (empty($headBuckets) === true && empty($baseBuckets) === true) { + echo "OK: none of the changed PHP files appear in either coverage report.\n"; + echo " Nothing was measured, so nothing is claimed about them.\n"; + exit(CG_OK); + } + + $keep = []; + foreach ($headBuckets as $key => $unused) { + $keep[$key] = true; + } + + $dropped = []; + foreach ($baseBuckets as $key => $pair) { + if (isset($keep[$key]) === false) { + $dropped[$key] = $pair; + } + } + + [$statements, $covered] = cgSumBuckets($headBuckets); + [$baseStatements, $baseCovered] = cgSumBuckets($baseBuckets, $keep); + + $current = ($statements > 0 ? round((($covered / $statements) * 100), 2) : 0.0); + $base = ($baseStatements > 0 ? round((($baseCovered / $baseStatements) * 100), 2) : 0.0); + + if (empty($dropped) === false) { + [$droppedStatements, $droppedCovered] = cgSumBuckets($dropped); + echo 'Removed from the base side: ' . count($dropped) + . " method(s) absent from head, {$droppedCovered}/{$droppedStatements} statements.\n"; + echo " A method that no longer exists is not a coverage regression; it is deleted code.\n"; + } + + cgReport('Surviving code, head:', $statements, $covered, $current); + cgReport('Surviving code, base:', $baseStatements, $baseCovered, $base); + + if ($baseStatements === 0) { + echo "OK: none of the changed PHP existed at the merge base, so there is no prior figure to drop below.\n"; + exit(CG_OK); + } + + if (cgRatioDropped($covered, $statements, $baseCovered, $baseStatements) === true) { + $delta = round(($base - $current), 2); + echo($delta > 0 + ? "FAIL: coverage of the code this change KEEPS or ADDS dropped by {$delta}%.\n" + : "FAIL: coverage of the code this change KEEPS or ADDS dropped by less than 0.01% — too " + . "little to show in the percentage, but a real loss in the counts below.\n"); + echo " base {$baseCovered}/{$baseStatements} -> head {$covered}/{$statements} statements, " + . "comparing only methods that exist on BOTH sides plus everything new on this branch.\n"; + + if ($statements > $baseStatements) { + $added = ($statements - $baseStatements); + echo " This change adds {$added} statements to those files. Adding code without tests drops coverage.\n"; + } + + echo " Deleted methods were already excluded, so this is not a deletion penalty. If you\n"; + echo " RENAMED a method, note that a rename reads as a delete plus an add: the new name is\n"; + echo " new code here and has to be covered.\n"; + + exit(CG_DROPPED); + } + + echo "OK: coverage of the surviving and added code did not drop.\n"; + exit(CG_OK); + }//end if + [$statements, $covered, $current] = cgMeasureFiles($cloverFile, 'current', $changed); [$baseStatements, $baseCovered, $base] = cgMeasureFiles($against, 'merge-base', $changed); diff --git a/tests/Unit/Service/UserServiceAccountPropertyCreationTest.php b/tests/Unit/Service/UserServiceAccountPropertyCreationTest.php new file mode 100644 index 0000000000..75a2442c32 --- /dev/null +++ b/tests/Unit/Service/UserServiceAccountPropertyCreationTest.php @@ -0,0 +1,227 @@ +getProperty($p) !== null`, + * but IAccount::getProperty() is declared `: IAccountProperty` and signals a + * missing property by THROWING PropertyDoesNotExistException — so the guard + * was always true, the `continue` always ran, and the create call below it + * never executed. The exception escaped to the method's outer catch, which + * logged a warning and abandoned the WHOLE account update, silently dropping + * the other fields in the same request. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @category Tests + * @package OCA\OpenRegister\Tests\Unit\Service + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service; + +use OCA\OpenRegister\Db\AuditTrailMapper; +use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\UserService; +use OCP\Accounts\IAccount; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\IAccountProperty; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAvatarManager; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\L10N\IFactory; +use OCP\Security\ISecureRandom; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Tests that a never-before-set profile field is created rather than dropped. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) UserService takes 13 collaborators. + * @SuppressWarnings(PHPMD.TooManyFields) One property per collaborator. + */ +class UserServiceAccountPropertyCreationTest extends TestCase { + private UserService $service; + private IUserManager&MockObject $userManager; + private IUserSession&MockObject $userSession; + private IConfig&MockObject $config; + private IGroupManager&MockObject $groupManager; + private IAccountManager&MockObject $accountManager; + private LoggerInterface&MockObject $logger; + + /** + * Wire UserService with mocked collaborators. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->userManager = $this->createMock(IUserManager::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->config = $this->createMock(IConfig::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->accountManager = $this->createMock(IAccountManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + + // updateUserProperties() snapshots the user via buildUserDataArray() + // before it touches anything, which reads the group list. + $this->groupManager->method('getUserGroups')->willReturn([]); + + $this->service = new UserService( + $this->userManager, + $this->userSession, + $this->config, + $this->groupManager, + $this->accountManager, + $this->logger, + $this->createMock(OrganisationService::class), + $this->createMock(IEventDispatcher::class), + $this->createMock(IAvatarManager::class), + $this->createMock(AuditTrailMapper::class), + $this->createMock(ISecureRandom::class), + $this->createMock(IDBConnection::class), + $this->createMock(IFactory::class) + ); + }//end setUp() + + /** + * Build a user whose display name and password are not being changed. + * + * @return IUser&MockObject The user under test. + */ + private function user(): IUser&MockObject { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('jan'); + $user->method('canChangeDisplayName')->willReturn(false); + $user->method('canChangePassword')->willReturn(false); + + return $user; + }//end user() + + /** + * A profile field the user has never set before is CREATED. + * + * This is the regression test for the unreachable create path: with the old + * `!== null` guard the account manager received no setProperty() call at all + * and updateAccount() was never reached. + * + * @return void + */ + public function testAProfileFieldTheUserHasNeverSetIsCreated(): void { + $user = $this->user(); + + $account = $this->createMock(IAccount::class); + // The property does not exist yet — the real IAccount throws here. + $account->method('getProperty') + ->willThrowException(new PropertyDoesNotExistException('phone')); + + $account->expects($this->once()) + ->method('setProperty') + ->with( + IAccountManager::PROPERTY_PHONE, + '+31 6 12345678', + $this->anything(), + IAccountManager::NOT_VERIFIED + ); + + $this->accountManager->method('getAccount')->willReturn($account); + $this->accountManager->expects($this->once())->method('updateAccount')->with($account); + + $result = $this->service->updateUserProperties($user, ['phone' => '+31 6 12345678']); + + $this->assertTrue($result['success'], 'Creating a new profile property must report success'); + }//end testAProfileFieldTheUserHasNeverSetIsCreated() + + /** + * A field the user already has is UPDATED in place, not re-created. + * + * The must-FAIL control for the test above: if the create path ran + * unconditionally, setProperty() would fire here too. + * + * @return void + */ + public function testAnExistingProfileFieldIsUpdatedInPlace(): void { + $user = $this->user(); + + $property = $this->createMock(IAccountProperty::class); + $property->method('getValue')->willReturn('old value'); + $property->expects($this->once())->method('setValue')->with('new value'); + + $account = $this->createMock(IAccount::class); + $account->method('getProperty')->willReturn($property); + $account->expects($this->never())->method('setProperty'); + + $this->accountManager->method('getAccount')->willReturn($account); + $this->accountManager->expects($this->once())->method('updateAccount')->with($account); + + $result = $this->service->updateUserProperties($user, ['phone' => 'new value']); + + $this->assertTrue($result['success']); + }//end testAnExistingProfileFieldIsUpdatedInPlace() + + /** + * One missing property does not abandon the other fields in the request. + * + * This is the user-visible half of the bug: the escaping exception aborted + * the whole loop, so a request setting three fields persisted none of them + * as soon as one of them was new. + * + * @return void + */ + public function testAMissingPropertyDoesNotDiscardTheOtherFieldsInTheSameRequest(): void { + $user = $this->user(); + + $existing = $this->createMock(IAccountProperty::class); + $existing->method('getValue')->willReturn('old'); + $existing->expects($this->once())->method('setValue')->with('https://example.org'); + + $account = $this->createMock(IAccount::class); + // `phone` is new (throws); `website` already exists (returns). + $account->method('getProperty')->willReturnCallback( + static function (string $property) use ($existing): IAccountProperty { + if ($property === IAccountManager::PROPERTY_PHONE) { + throw new PropertyDoesNotExistException($property); + } + + return $existing; + } + ); + + $account->expects($this->once()) + ->method('setProperty') + ->with(IAccountManager::PROPERTY_PHONE, '+31 6 12345678', $this->anything(), IAccountManager::NOT_VERIFIED); + + $this->accountManager->method('getAccount')->willReturn($account); + $this->accountManager->expects($this->once())->method('updateAccount')->with($account); + + $result = $this->service->updateUserProperties( + $user, + [ + 'phone' => '+31 6 12345678', + 'website' => 'https://example.org', + ] + ); + + $this->assertTrue($result['success']); + }//end testAMissingPropertyDoesNotDiscardTheOtherFieldsInTheSameRequest() +}//end class diff --git a/tests/stubs/NextcloudInternalStubs.php b/tests/stubs/NextcloudInternalStubs.php index dfa6d8e11c..12fb584a3e 100644 --- a/tests/stubs/NextcloudInternalStubs.php +++ b/tests/stubs/NextcloudInternalStubs.php @@ -211,6 +211,34 @@ public function getFormat(): ?string { return null; } return $req; }); + // Nextcloud 34 made Response::getHeaders() resolve IUserSession from the + // container to stamp an X-User-Id header, and Response::cacheFor() resolve + // ITimeFactory. Neither was registered here, so ANY test that read a + // response header or set a cache lifetime died on "getUser()/getTime() on + // null" — 30 of them. Anonymous classes rather than interface + // implementations, matching the note above: the stub must survive a + // signature change in either interface. + OC::$server->registerService(\OCP\IUserSession::class, function() { + return new class { + /** @return mixed Always null: unit tests run unauthenticated. */ + public function getUser() { return null; } + public function isLoggedIn(): bool { return false; } + }; + }); + + OC::$server->registerService(\OCP\AppFramework\Utility\ITimeFactory::class, function() { + return new class { + public function getTime(): int { return 1700000000; } + public function getDateTime(string $type = "now", ?\DateTimeZone $timezone = null): \DateTime { + return new \DateTime("@1700000000"); + } + public function now(): \DateTimeImmutable { return new \DateTimeImmutable("@1700000000"); } + public function getTimeZone(?string $timezone = null): \DateTimeZone { + return new \DateTimeZone($timezone ?? "UTC"); + } + }; + }); + // Pre-register a minimal IFactory (L10N factory) so OCP\Util::addInitScript() // and OCP\Util::addTranslations() can call findLanguage() without crashing. OC::$server->registerService(\OCP\L10N\IFactory::class, function() { @@ -315,6 +343,46 @@ interface IToken {}'); class Request {}'); }//end if +// ----------------------------------------------------------------- +// OC\Security\CSP\ContentSecurityPolicyNonceManager +// +// GraphQLController type-hints this internal server class to obtain the CSP +// nonce for the GraphiQL shell. It is not part of nextcloud/ocp, so +// createMock() on it threw UnknownTypeException and the three explorer tests +// could never run. +// ----------------------------------------------------------------- +if (class_exists(\OC\Security\CSP\ContentSecurityPolicyNonceManager::class) === false) { + eval('namespace OC\Security\CSP; + class ContentSecurityPolicyNonceManager { + public function getNonce(): string { return ""; } + }'); +}//end if + +// ----------------------------------------------------------------- +// OC\Security\CSRF\CsrfToken + CsrfTokenManager +// +// Same story as the nonce manager above: GraphQLController type-hints these +// internal server classes to stamp a request token into the GraphiQL fetcher. +// ----------------------------------------------------------------- +if (class_exists(\OC\Security\CSRF\CsrfToken::class) === false) { + eval('namespace OC\Security\CSRF; + class CsrfToken { + public function getName(): string { return ""; } + public function getValue(): string { return ""; } + public function getEncryptedValue(): string { return ""; } + }'); +}//end if + +if (class_exists(\OC\Security\CSRF\CsrfTokenManager::class) === false) { + eval('namespace OC\Security\CSRF; + class CsrfTokenManager { + public function getToken(): CsrfToken { return new CsrfToken(); } + public function refreshToken(): CsrfToken { return new CsrfToken(); } + public function removeToken(): void { } + public function isTokenValid(CsrfToken $token): bool { return true; } + }'); +}//end if + // ----------------------------------------------------------------- // OC\AppFramework\Middleware\Security\Exceptions\NotAdminException // ----------------------------------------------------------------- From 40188295170dd33a6129280c1c2f7cca71c71b1d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 22 Aug 2026 10:24:35 +0200 Subject: [PATCH 046/139] chore(deps): refresh the shared Conduction locks (#2777) 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.2 -> v1.9.0 nc-vue 2.10.1 -> 2.11.1 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(info.xml): point shipped URLs at GitHub, not the retired Codeberg host gate-94 (retired-git-host-metadata, added in ConductionNL/.github#546) flags six shipped URLs here: website, bugs, repository and three screenshots, all still pointing at codeberg.org. GitHub is the only host; Codeberg is retired. The screenshots move to raw.githubusercontent.com rather than a github.com/raw redirect, and all three were checked rather than assumed — HTTP 200 each. A green gate with dead image URLs would be worse than the finding. Not caused by this branch's lock bump: gate-94 landed at 02:18 UTC and this app's last development run was 01:51 UTC, so the PR runs are simply the first to be measured against it. Pre-existing debt, newly visible. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Conduction Release Bot --- appinfo/info.xml | 13 ++++++------- composer.lock | 17 ++++++----------- package-lock.json | 6 +++--- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index e1644bb889..270c42aa4c 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -52,13 +52,13 @@ Vrij en open source onder de EUPL-licentie. organization tools search - https://codeberg.org/Conduction/openregister - https://codeberg.org/Conduction/openregister/issues - https://codeberg.org/Conduction/openregister + https://github.com/ConductionNL/openregister + https://github.com/ConductionNL/openregister/issues + https://github.com/ConductionNL/openregister - https://codeberg.org/Conduction/openregister/raw/branch/main/img/screenshot-dashboard.png - https://codeberg.org/Conduction/openregister/raw/branch/main/img/screenshot-registers.png - https://codeberg.org/Conduction/openregister/raw/branch/main/img/screenshot-objects.png + https://raw.githubusercontent.com/ConductionNL/openregister/main/img/screenshot-dashboard.png + https://raw.githubusercontent.com/ConductionNL/openregister/main/img/screenshot-registers.png + https://raw.githubusercontent.com/ConductionNL/openregister/main/img/screenshot-objects.png @@ -220,7 +220,6 @@ 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/composer.lock b/composer.lock index a39dc2fd4e..3395109801 100644 --- a/composer.lock +++ b/composer.lock @@ -7048,16 +7048,16 @@ }, { "name": "conduction/hydra-gates", - "version": "v1.8.2", + "version": "v1.9.0", "source": { "type": "git", "url": "https://github.com/ConductionNL/.github.git", - "reference": "3dfcd1e56d27bd06eaa98a9a66e377e7e14fe491" + "reference": "9801ffdbff17d05f0934742190742d733ac912b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ConductionNL/.github/zipball/3dfcd1e56d27bd06eaa98a9a66e377e7e14fe491", - "reference": "3dfcd1e56d27bd06eaa98a9a66e377e7e14fe491", + "url": "https://api.github.com/repos/ConductionNL/.github/zipball/9801ffdbff17d05f0934742190742d733ac912b4", + "reference": "9801ffdbff17d05f0934742190742d733ac912b4", "shasum": "" }, "require": { @@ -7074,11 +7074,6 @@ "schemas": "hydra-gates/scripts/schemas" } }, - "autoload": { - "psr-4": { - "OCA\\OpenRegister\\Contract\\": "hydra-gates/contracts/" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "EUPL-1.2" @@ -7101,9 +7096,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.2" + "source": "https://github.com/ConductionNL/.github/tree/v1.9.0" }, - "time": "2026-08-20T09:37:12+00:00" + "time": "2026-08-22T00:30:18+00:00" }, { "name": "consolidation/annotated-command", diff --git a/package-lock.json b/package-lock.json index e05a3e64e0..c1d9547dce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2112,9 +2112,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "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==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.11.1.tgz", + "integrity": "sha512-E5oB7KUXL2JO68nMItsmL8kepmx6omI6SqDnKm04U7y81ipRASeyyh9bIB9ViT8/yHsqjBIF2VkwnvIwcuTfFQ==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", From 5e7471bed11d383997b65b6206d2b44f128a7b36 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 22 Aug 2026 10:24:42 +0200 Subject: [PATCH 047/139] fix(scope): make {register} a hard boundary on every path that names one (#2774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scope): make {register} a hard boundary on every path that names one Found live on a fleet instance 2026-08-21, on the endpoint the dashboard `stat` widget calls: GET /api/objects/aggregations/{register}/{schema}/value accepted the {register} path segment and never used it. AggregationRunner::run() and ::runAdhocByRef() both opened with a GLOBAL SchemaMapper::find($schemaRef) and loaded the register afterwards, so by the time the register was known the schema had already been matched against every register and every app on the instance. `TimeEntry` resolved to planix's schema 161 instead of hrmq's 9466, and `Expense` to pipelinq's 507 instead of hrmq's 5026 — a widget aggregating another app's rows. Single-app instances and CI cannot reproduce it. This is the defect class #2694 fixed for schemas#show. That fix was written INLINE and PRIVATE in SchemasController, so none of the dozen other sites that hold a register ref could reuse it, and each went on resolving globally. The resolution is therefore extracted, not copied: lib/Service/RegisterScopedSchemaResolver.php — resolveRegister() / resolveSchemaWithin() / resolvePair(), with #2694's exact semantics: every identifier form (numeric id, uuid, case-insensitive slug) matched only among the register's carried ids via SchemaMapper::findInIds(), an unresolvable register refused with RegisterNotFoundException instead of widening back to global, and a scoped miss refused with SchemaNotInRegisterException carrying the register, the same-slug candidate count and the relink-schemas command. Sites converted to it, all previously resolving instance-wide with a register in hand: - AggregationRunner::run/runAdhocByRef/findSchema — aggregate, value, grouped, timeseries. The mis-resolution drove the x-openregister-aggregations annotation AND the RBAC `list` gate, not just the number. - ObjectService::setRegister/setSchema — scoping applied to SLUGS only (a numeric id or uuid still resolved globally) and only when setRegister ran FIRST. The inverted order is the shape of the copy-pasted validateObject() helper in ~24 link controllers and of every resolution in FilesController, so setRegister() now re-resolves a pending schema ref: the boundary holds whichever way round the setters are called, fixed in one place. - MigrationService::resolveRegisterAndSchema — status + MigrateStorageCommand. - QualityStatisticsService / DuplicateDetectionService loadAnnotation() — the object set was loaded with both refs while the annotation drifted, so another app's thresholds and match rules judged this register's rows. - ContextsController::schema — a published, cacheable JSON-LD @context. - TmloController exportSingle/exportBatch/summary — archival metadata. - TablesController::sync/syncAll — DDL against oc_openregister_table__. - ObjectsController::export — reuses the already-scoped entities instead of re-resolving globally for the filename. - SchemasController::resolveSchemaInRegister — now delegates, so one implementation owns the wording and the identifier forms. Behaviour is unchanged for every caller that names no register: findSchema() without a registerRef, the cross-schema `from:` target (a schema-author ref that may legitimately point at another register), and ObjectService with no register context all keep global resolution. AggregationRunner::loadRegister() is removed rather than left behind: a path that resolves a register WITHOUT then bounding a schema by it is the shape this change deletes. Its metadata-read policy (_multitenancy: false) is unchanged, moved into the resolver, and still locked by AggregationRunnerTest::testRegisterLoadPassesMultitenancyFalse. No spec Scenario is added — gate-19 requires an e2e reference for a new one, and #2694 set the precedent of documenting in docblocks instead. Every touched method carries @spec openspec/specs/register-scoped-slug-resolution/spec.md. Tests: new AggregationRegisterScopeTest mirrors #2694's style — scoped hit (global resolver asserted never to run), scoped miss with the slug existing elsewhere, unknown register, numeric id outside the register, empty schemas list, and both halves of the control (register-less callers keep global resolution; a named register never reaches it). * fix(scope): carry the boundary through the remaining resolution sites Completes the sweep: ObjectService::setRegister() re-resolves a pending schema ref (which fixes the ~45 call sites that call setSchema before setRegister in one place instead of 45 edits), plus MigrationService and the two quality services, with the matching controller/service tests. * chore(metadata): point shipped URLs at GitHub, not the retired Codeberg host gate-94 (retired-git-host-metadata) flags six URLs in appinfo/info.xml — website, bugs, repository and three screenshots — that still point at codeberg.org. Codeberg is retired for this fleet; GitHub is the only host, issues included. Screenshots move to raw.githubusercontent.com, GitHub's equivalent of the raw branch path. * test(scope): stub the scoped schema lookup in every ContextsController test The controller now resolves a schema within the register's carried ids (findInIds) rather than instance-wide, so the three tests that stubbed only find() fell through to an unresolved schema. testConditionalGetReturns304 surfaced it as a TypeError — the error response carries no ETag, and the test assigns that null into a typed string property. That test skips outside the Nextcloud container, which is why CI caught it and a standalone clone could not. * fix(scope): bound SLUG resolution to the register, not unique identifiers The boundary was applied to every identifier form, which is stricter than the problem it solves and broke real callers: POST /api/objects/{registerId}/{schemaId} began returning 404 whenever the register's schemas array did not list the schema — measured as 10 failed assertions in the Newman suite, where the first failed create cascaded into every later request. A slug is ambiguous instance-wide (several registers legitimately carry a TimeEntry) and that ambiguity is the entire bug: resolving one globally served another app's schema into a leaf app's forms and aggregations. A numeric id or uuid is unique by construction, so scoping it protects nothing and can only refuse a caller whose membership list is stale. Unique identifiers therefore resolve globally, and the list is treated as the cache it is; a genuinely absent identifier still refuses, because the widening is for ambiguity and never for absence. Two tests that pinned the over-strict rule now pin the corrected one, and a dedicated resolver suite covers both halves. * test(scope): pin the sync refusal to a SLUG, the only ambiguous form The test refused a numeric schema id, which the narrowed rule now resolves globally — a numeric id is unique by construction, so scoping it protects nothing and only punishes a stale membership list. The refusal it means to prove is a slug refusal, so it now uses one. --------- Co-authored-by: Conduction Release Bot --- lib/Controller/AggregationController.php | 8 +- lib/Controller/ContextsController.php | 17 +- lib/Controller/ObjectsController.php | 21 +- lib/Controller/SchemasController.php | 57 +-- lib/Controller/TablesController.php | 68 ++- lib/Controller/TmloController.php | 68 ++- lib/Service/Aggregation/AggregationRunner.php | 141 ++++-- lib/Service/MigrationService.php | 47 +- lib/Service/ObjectService.php | 137 ++++-- .../Quality/DuplicateDetectionService.php | 52 ++- .../Quality/QualityStatisticsService.php | 51 ++- lib/Service/RegisterScopedSchemaResolver.php | 250 ++++++++++ .../Controller/ContextsControllerTest.php | 9 + .../Unit/Controller/ObjectsControllerTest.php | 36 ++ ...SchemasControllerShowRegisterScopeTest.php | 20 +- .../Unit/Controller/TablesControllerTest.php | 56 ++- .../AggregationRegisterScopeTest.php | 431 ++++++++++++++++++ .../Aggregation/AggregationRunnerTest.php | 31 +- .../CrossSchemaAggregationRunnerTest.php | 28 ++ tests/Unit/Service/MigrationServiceTest.php | 15 +- .../Quality/DuplicateDetectionServiceTest.php | 37 +- .../Quality/QualityStatisticsServiceTest.php | 29 +- .../RegisterScopedSchemaResolverTest.php | 145 ++++++ 23 files changed, 1571 insertions(+), 183 deletions(-) create mode 100644 lib/Service/RegisterScopedSchemaResolver.php create mode 100644 tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php create mode 100644 tests/Unit/Service/RegisterScopedSchemaResolverTest.php diff --git a/lib/Controller/AggregationController.php b/lib/Controller/AggregationController.php index 009ebfe49a..1abff24f4a 100644 --- a/lib/Controller/AggregationController.php +++ b/lib/Controller/AggregationController.php @@ -413,8 +413,14 @@ public function timeseries(string $register, string $schema): JSONResponse { // Resolve schema first so the validator can consult the // declared property list. A missing schema is a 404; a bad // query-param shape is a 400. + // + // The `{register}` path segment is passed through: this lookup must + // resolve the SAME schema that runAdhocByRef() resolves below, and the + // register is what disambiguates a slug several apps share. Without it + // the validator would police one app's property list while the + // aggregate ran over another's rows. try { - $schemaEntity = $this->runner->findSchema(schemaRef: $schema); + $schemaEntity = $this->runner->findSchema(schemaRef: $schema, registerRef: $register); } catch (RuntimeException $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); } diff --git a/lib/Controller/ContextsController.php b/lib/Controller/ContextsController.php index 957720fdde..823d24035f 100644 --- a/lib/Controller/ContextsController.php +++ b/lib/Controller/ContextsController.php @@ -32,6 +32,7 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\JsonLd\JsonLdContextService; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\Attribute\AnonRateLimit; use OCP\AppFramework\Db\DoesNotExistException; @@ -120,10 +121,22 @@ public function schema(string $register, string $schema): DataResponse { return new DataResponse(['error' => 'Register not found'], Http::STATUS_NOT_FOUND); } + // REGISTER-SCOPED. The two refs used to be resolved INDEPENDENTLY, so the + // `{register}` segment named a register whose context document then + // described a schema from somewhere else entirely — a JSON-LD `@context` + // is a published contract, and publishing another app's term definitions + // under this register's URL is a durable, cacheable wrong answer. + // + // The sibling {@see loadSchemas()} was already scoped: it walks + // `$register->getSchemas()`. This is the same boundary, applied to the + // single-schema variant. try { - $schemaEntity = $this->schemaMapper->find($schema); + $schemaEntity = (new RegisterScopedSchemaResolver( + registerMapper: $this->registerMapper, + schemaMapper: $this->schemaMapper + ))->resolveSchemaWithin(register: $registerEntity, schemaRef: $schema); } catch (DoesNotExistException|\Exception $e) { - return new DataResponse(['error' => 'Schema not found'], Http::STATUS_NOT_FOUND); + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); } $contextMap = $this->contextService->buildSchemaContext(register: $registerEntity, schema: $schemaEntity); diff --git a/lib/Controller/ObjectsController.php b/lib/Controller/ObjectsController.php index 790acbffc9..18d3442767 100644 --- a/lib/Controller/ObjectsController.php +++ b/lib/Controller/ObjectsController.php @@ -4040,10 +4040,23 @@ public function export(string $register, string $schema, ObjectService $objectSe $type = $this->request->getParam(key: 'format') ?? $this->request->getParam(key: 'type', default: 'excel'); // Get register and schema entities. - // Bypass multi-tenancy since the user already has access via setRegister/setSchema above, - // and this lookup is only needed for the export filename and metadata. - $registerEntity = $this->registerMapper->find($register, _multitenancy: false); - $schemaEntity = $this->schemaMapper->find($schema, _multitenancy: false); + // + // Reuse what setRegister()/setSchema() already resolved instead of + // re-resolving. The re-resolution was GLOBAL — `$this->schemaMapper->find()` + // matches `LOWER(slug)` across the whole instance — so on an instance where + // two apps share a schema slug the export was named after ANOTHER app's + // schema while its rows came from this register's. A filename is exactly + // where that goes unnoticed: the file downloads, opens, and lies about its + // own provenance. + $registerEntity = $objectService->getCurrentRegisterEntity(); + $schemaEntity = $objectService->getCurrentSchemaEntity(); + if ($registerEntity === null || $schemaEntity === null) { + // Unreachable in practice — setRegister()/setSchema() above either + // resolve or throw. Refusing here rather than re-resolving keeps the + // register a boundary: a fallback lookup would be a second, unscoped + // chance to find *a* schema with this slug, which is the whole defect. + return new JSONResponse(data: ['error' => 'Register or schema not found'], statusCode: 404); + } // Generate filename base. $filenameBase = sprintf( diff --git a/lib/Controller/SchemasController.php b/lib/Controller/SchemasController.php index 1185d30998..4eb2694ce5 100644 --- a/lib/Controller/SchemasController.php +++ b/lib/Controller/SchemasController.php @@ -40,6 +40,7 @@ use OCA\OpenRegister\Service\AuthorizationAuditService; use OCA\OpenRegister\Service\JsonLd\JsonLdContextService; use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; use OCA\OpenRegister\Service\Schema\SchemaVersioningService; use OCA\OpenRegister\Service\SchemaDeletionService; use OCA\OpenRegister\Service\SchemaImport\ImportOptions; @@ -97,6 +98,19 @@ class SchemasController extends Controller { use \OCA\OpenRegister\Controller\Trait\HandlesExceptionsTrait; + /** + * The shared register-scoped schema resolver. + * + * Built here rather than injected: it is a stateless collaborator over the + * `RegisterMapper` + `SchemaMapper` this class already holds, so constructing + * it directly keeps every existing unit test — all of which mock those two + * mappers — exercising the REAL resolution path instead of a mock of the very + * thing under test. + * + * @var RegisterScopedSchemaResolver + */ + private readonly RegisterScopedSchemaResolver $scopedSchemaResolver; + /** * Constructor * @@ -148,6 +162,10 @@ public function __construct( ) { // Call parent constructor to initialize base controller. parent::__construct(appName: $appName, request: $request); + $this->scopedSchemaResolver = new RegisterScopedSchemaResolver( + registerMapper: $registerMapper, + schemaMapper: $schemaMapper + ); }//end __construct() /** @@ -372,35 +390,20 @@ private function resolveSchema(int|string $id): Schema { * @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.' - ); - } + // Delegated to the shared resolver rather than kept inline. This method was + // the FIRST implementation of the boundary and, being private, could not be + // reused — so the aggregation endpoints, which carry a `{register}` path + // segment, went on resolving globally and served another app's rows + // (measured 2026-08-21: `TimeEntry` → planix schema 161 instead of hrmq's + // 9466). One implementation is the only way the wording, the identifier + // forms and the refusal stay identical across every surface. + return $this->scopedSchemaResolver->resolvePair( + registerRef: $registerParam, + schemaRef: $id + )['schema']; + }//end resolveSchemaInRegister() - $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() /** diff --git a/lib/Controller/TablesController.php b/lib/Controller/TablesController.php index e8030ad3cf..276f059e2b 100644 --- a/lib/Controller/TablesController.php +++ b/lib/Controller/TablesController.php @@ -24,6 +24,7 @@ use OCA\OpenRegister\Db\MagicMapper; use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\JSONResponse; use OCP\IAppConfig; @@ -39,6 +40,21 @@ * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ class TablesController extends Controller { + + /** + * The shared register-scoped schema resolver. + * + * Built here rather than injected: it is a stateless collaborator over the + * `RegisterMapper` + `SchemaMapper` this class already holds, so constructing + * it directly keeps every existing unit test — all of which mock those two + * mappers — exercising the REAL resolution path instead of a mock of the very + * thing under test. + * + * @var RegisterScopedSchemaResolver + */ + private readonly RegisterScopedSchemaResolver $scopedSchemaResolver; + + /** * Constructor * @@ -56,10 +72,14 @@ public function __construct( private readonly IAppConfig $config, private readonly MagicMapper $magicMapper, private readonly RegisterMapper $registerMapper, - private readonly SchemaMapper $schemaMapper, + SchemaMapper $schemaMapper, private readonly LoggerInterface $logger, ) { parent::__construct(appName: $appName, request: $request); + $this->scopedSchemaResolver = new RegisterScopedSchemaResolver( + registerMapper: $registerMapper, + schemaMapper: $schemaMapper + ); }//end __construct() /** @@ -98,18 +118,21 @@ public function sync(int|string $registerId, int|string $schemaId): JSONResponse return new JSONResponse(['error' => 'Register not found'], 404); } - // Find schema. - $schema = null; - if (is_numeric($schemaId) === true) { - $schema = $this->schemaMapper->find((int)$schemaId); - } - - if (is_numeric($schemaId) === false) { - $schema = $this->schemaMapper->findBySlug($schemaId); - } - - if ($schema === null) { - return new JSONResponse(['error' => 'Schema not found'], 404); + // Find schema — REGISTER-SCOPED. + // + // This used to be a global `find()` for a numeric id and a global + // `findBySlug()` for a slug, so the register just resolved above was + // never a boundary. Syncing a table is a SCHEMA-SHAPED DDL operation + // against `oc_openregister_table__`: resolving to + // another app's same-slug schema would reshape this register's table to + // a foreign definition. Scoping makes that unreachable. + try { + $schema = $this->scopedSchemaResolver->resolveSchemaWithin( + register: $register, + schemaRef: $schemaId + ); + } catch (\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], 404); } // Trigger table sync (without dropping/recreating). @@ -233,18 +256,13 @@ public function syncAll(): JSONResponse { } try { - $schema = null; - if (is_numeric($schemaId) === true) { - $schema = $this->schemaMapper->find((int)$schemaId); - } - - if (is_numeric($schemaId) === false) { - $schema = $this->schemaMapper->findBySlug((string)$schemaId); - } - - if ($schema === null) { - continue; - } + // REGISTER-SCOPED — see sync(). The ref comes from THIS + // register's own schemas list, so resolving it anywhere + // else can only ever be wrong. + $schema = $this->scopedSchemaResolver->resolveSchemaWithin( + register: $register, + schemaRef: $schemaId + ); $this->magicMapper->syncTableForRegisterSchema( register: $register, diff --git a/lib/Controller/TmloController.php b/lib/Controller/TmloController.php index 186ab16b3d..5f8968870a 100644 --- a/lib/Controller/TmloController.php +++ b/lib/Controller/TmloController.php @@ -29,7 +29,9 @@ use InvalidArgumentException; use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\SchemaNotInRegisterException; use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; use OCA\OpenRegister\Service\TmloService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; @@ -50,6 +52,21 @@ * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class TmloController extends Controller { + + /** + * The shared register-scoped schema resolver. + * + * Built here rather than injected: it is a stateless collaborator over the + * `RegisterMapper` + `SchemaMapper` this class already holds, so constructing + * it directly keeps every existing unit test — all of which mock those two + * mappers — exercising the REAL resolution path instead of a mock of the very + * thing under test. + * + * @var RegisterScopedSchemaResolver + */ + private readonly RegisterScopedSchemaResolver $scopedSchemaResolver; + + /** * Constructor. * @@ -69,10 +86,14 @@ public function __construct( private readonly TmloService $tmloService, private readonly ObjectService $objectService, private readonly RegisterMapper $registerMapper, - private readonly SchemaMapper $schemaMapper, + SchemaMapper $schemaMapper, private readonly LoggerInterface $logger, ) { parent::__construct(appName: $appName, request: $request); + $this->scopedSchemaResolver = new RegisterScopedSchemaResolver( + registerMapper: $registerMapper, + schemaMapper: $schemaMapper + ); }//end __construct() /** @@ -94,7 +115,14 @@ public function __construct( public function exportSingle(string $register, string $schema, string $id): Response { try { $registerEntity = $this->registerMapper->find($register); - $schemaEntity = $this->schemaMapper->find($schema); + // REGISTER-SCOPED: the schema ref resolves among the ids this register + // carries, never instance-wide. The two used to be resolved + // independently, so a `{register}`/`{schema}` pair could export the + // archival metadata of another app's same-slug schema. + $schemaEntity = $this->scopedSchemaResolver->resolveSchemaWithin( + register: $registerEntity, + schemaRef: $schema + ); $object = $this->objectService->find( identifier: $id, @@ -107,6 +135,13 @@ public function exportSingle(string $register, string $schema, string $id): Resp $response = new DataResponse($xml, Http::STATUS_OK); $response->addHeader('Content-Type', 'application/xml; charset=UTF-8'); return $response; + } catch (SchemaNotInRegisterException $e) { + // The register-scoped refusal carries a diagnosis — which register, how + // many same-slug schemas exist elsewhere, the relink-schemas repair + // command. Flattening it into the generic 'not found' below would read + // as "your slug is wrong", the one conclusion that is certainly false + // when duplicates demonstrably exist. + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); } catch (DoesNotExistException $e) { return new JSONResponse( ['error' => 'Register or schema not found'], @@ -144,7 +179,14 @@ public function exportSingle(string $register, string $schema, string $id): Resp public function exportBatch(string $register, string $schema): Response { try { $registerEntity = $this->registerMapper->find($register); - $schemaEntity = $this->schemaMapper->find($schema); + // REGISTER-SCOPED: the schema ref resolves among the ids this register + // carries, never instance-wide. The two used to be resolved + // independently, so a `{register}`/`{schema}` pair could export the + // archival metadata of another app's same-slug schema. + $schemaEntity = $this->scopedSchemaResolver->resolveSchemaWithin( + register: $registerEntity, + schemaRef: $schema + ); // Get all query parameters for filtering. $params = $this->request->getParams(); @@ -168,6 +210,13 @@ public function exportBatch(string $register, string $schema): Response { $response = new DataResponse($xml, Http::STATUS_OK); $response->addHeader('Content-Type', 'application/xml; charset=UTF-8'); return $response; + } catch (SchemaNotInRegisterException $e) { + // The register-scoped refusal carries a diagnosis — which register, how + // many same-slug schemas exist elsewhere, the relink-schemas repair + // command. Flattening it into the generic 'not found' below would read + // as "your slug is wrong", the one conclusion that is certainly false + // when duplicates demonstrably exist. + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); } catch (DoesNotExistException $e) { return new JSONResponse( ['error' => 'Register or schema not found'], @@ -210,7 +259,11 @@ public function summary(string $register, string $schema): JSONResponse { ); } - $schemaEntity = $this->schemaMapper->find($schema); + // REGISTER-SCOPED — see exportSingle(). + $schemaEntity = $this->scopedSchemaResolver->resolveSchemaWithin( + register: $registerEntity, + schemaRef: $schema + ); // Initialize counts. $counts = [ @@ -231,6 +284,13 @@ public function summary(string $register, string $schema): JSONResponse { } return new JSONResponse($counts, Http::STATUS_OK); + } catch (SchemaNotInRegisterException $e) { + // The register-scoped refusal carries a diagnosis — which register, how + // many same-slug schemas exist elsewhere, the relink-schemas repair + // command. Flattening it into the generic 'not found' below would read + // as "your slug is wrong", the one conclusion that is certainly false + // when duplicates demonstrably exist. + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_NOT_FOUND); } catch (DoesNotExistException $e) { // Unknown register/schema slug or id: return a clean 404 instead of // leaking the internal DBAL SQL through the generic 500 handler. diff --git a/lib/Service/Aggregation/AggregationRunner.php b/lib/Service/Aggregation/AggregationRunner.php index 367d4e6057..713a3e918b 100644 --- a/lib/Service/Aggregation/AggregationRunner.php +++ b/lib/Service/Aggregation/AggregationRunner.php @@ -44,11 +44,14 @@ use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Exception\NotAuthorizedException; +use OCA\OpenRegister\Exception\RegisterNotFoundException; +use OCA\OpenRegister\Exception\SchemaNotInRegisterException; use OCA\OpenRegister\Service\LanguageService; use OCA\OpenRegister\Service\Object\PermissionHandler; use OCA\OpenRegister\Service\Object\TranslationHandler; use OCA\OpenRegister\Service\ObjectSource\DbalObjectSourceProvider; use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; use OCA\OpenRegister\Service\Search\PlaceholderResolver; use OCP\IDBConnection; use OCP\IUserSession; @@ -92,6 +95,19 @@ class AggregationRunner { */ private const PHP_FALLBACK_ROW_CAP = 10000; + /** + * The shared register-scoped schema resolver. + * + * Built here rather than injected: it is a stateless collaborator over the + * `RegisterMapper` + `SchemaMapper` this class already holds, so constructing + * it directly keeps every existing unit test — all of which mock those two + * mappers — exercising the REAL resolution path instead of a mock of the very + * thing under test. + * + * @var RegisterScopedSchemaResolver + */ + private readonly RegisterScopedSchemaResolver $scopedResolver; + /** * Constructor. * @@ -132,8 +148,13 @@ public function __construct( private readonly ?LoggerInterface $logger = null, private readonly ?DbalObjectSourceProvider $dbalSourceProvider = null, ) { + $this->scopedResolver = new RegisterScopedSchemaResolver( + registerMapper: $registerMapper, + schemaMapper: $schemaMapper + ); }//end __construct() + /** * Run the named aggregation on the given (register, schema). * @@ -182,8 +203,13 @@ public function run( bool $bypassRbac = false, array $parentRow = [], ): array { - $schema = $this->loadSchema(schemaRef: $schemaRef); - $register = $this->loadRegister(registerRef: $registerRef); + // REGISTER-SCOPED RESOLUTION. The register is the boundary and is + // resolved FIRST; the schema ref is then matched only among the schemas + // that register carries. See loadSchemaInRegister() for the measured + // cross-app read the previous schema-first ordering produced. + $pair = $this->loadSchemaInRegister(schemaRef: $schemaRef, registerRef: $registerRef); + $schema = $pair['schema']; + $register = $pair['register']; // SECURITY: gate aggregation behind list-permission on the schema // before any native or fallback path executes. Without this gate, @@ -862,10 +888,12 @@ public function runAdhocByRef( string $schemaRef, AggregationQuery $query, ): array { - $schema = $this->loadSchema(schemaRef: $schemaRef); - $register = $this->loadRegister(registerRef: $registerRef); + // REGISTER-SCOPED RESOLUTION — see run() and loadSchemaInRegister(). + // `value`, `grouped` and `timeseries` all land here, and all three are + // the surfaces the dashboard `stat`/`chart` widgets call. + $pair = $this->loadSchemaInRegister(schemaRef: $schemaRef, registerRef: $registerRef); - return $this->runAdhoc(register: $register, schema: $schema, query: $query); + return $this->runAdhoc(register: $pair['register'], schema: $pair['schema'], query: $query); }//end runAdhocByRef() /** @@ -875,17 +903,29 @@ public function runAdhocByRef( * (we want a 400 from the validation layer, not a 404 from inside * runAdhocByRef()). * - * @param string $schemaRef Schema slug/uuid/id. + * When `$registerRef` is supplied the lookup is REGISTER-SCOPED and must + * resolve to the same schema `runAdhocByRef()` will subsequently resolve — + * `timeseries()` calls both, and a validator that validated the field + * allow-list of one schema while the aggregate ran against another would be + * worse than no validation at all. Callers with no register boundary in hand + * omit it and keep global resolution. + * + * @param string $schemaRef Schema slug/uuid/id. + * @param string|null $registerRef Optional register slug/uuid/id — when given, the boundary. * * @return Schema The loaded schema. * - * @throws RuntimeException When the schema can't be found. + * @throws RuntimeException When the schema can't be found, or is not carried by the register. * - * @spec exclude Thin public convenience wrapper over the private loadSchema mapper lookup; exposed only so + * @spec exclude Thin public convenience wrapper over the private schema lookups; exposed only so * the REST controller can validate field allow-lists before building an AggregationQuery. * No business logic of its own. */ - public function findSchema(string $schemaRef): Schema { + public function findSchema(string $schemaRef, ?string $registerRef = null): Schema { + if ($registerRef !== null && $registerRef !== '') { + return $this->loadSchemaInRegister(schemaRef: $schemaRef, registerRef: $registerRef)['schema']; + } + return $this->loadSchema(schemaRef: $schemaRef); }//end findSchema() @@ -2761,9 +2801,63 @@ private function findRegisterForSchema(Schema $schema): ?Register { return null; }//end findRegisterForSchema() + /** + * Resolve the (register, schema) pair a request named, with the register as the boundary. + * + * WHY THE PAIR IS RESOLVED TOGETHER. Every aggregation endpoint carries a + * `{register}` path segment, and until this method existed not one of them used + * it to disambiguate: `run()` and `runAdhocByRef()` both opened with + * `loadSchema($schemaRef)` and only then loaded the register, so by the time the + * register was known the schema had already been matched against every register + * and every app on the instance. Measured on the shared dev instance 2026-08-21, + * a dashboard `stat` widget therefore aggregated ANOTHER APP's rows: `TimeEntry` + * resolved to planix's schema 161 instead of hrmq's 9466, and `Expense` to + * pipelinq's 507 instead of hrmq's 5026. The register was in hand the whole + * time; the ordering threw it away. + * + * The consequence is not only a wrong number. The `x-openregister-aggregations` + * annotation, the RBAC `list` gate and the object-row scan are ALL driven off + * the resolved schema, so a mis-resolution silently evaluates permissions + * against the wrong schema's authorization config. + * + * @param string $schemaRef Schema slug/uuid/id. + * @param string $registerRef Register slug/uuid/id — the boundary. + * + * @return array{register: Register, schema: Schema} The resolved pair. + * + * @throws RuntimeException When the register does not resolve, or does not carry the schema. + * + * @spec openspec/specs/register-scoped-slug-resolution/spec.md + */ + private function loadSchemaInRegister(string $schemaRef, string $registerRef): array { + try { + $pair = $this->scopedResolver->resolvePair(registerRef: $registerRef, schemaRef: $schemaRef); + } catch (SchemaNotInRegisterException | RegisterNotFoundException $e) { + // Re-thrown as RuntimeException so the existing controller contract + // holds: AggregationController maps RuntimeException to HTTP 404 and + // echoes the message. The message is carried through UNCHANGED — which + // register, how many same-slug schemas exist elsewhere, and the + // relink-schemas repair command are the whole point of the refusal. + // Flattening it to `Schema "%s" not found.` would read as "your slug is + // wrong", the one conclusion that is certainly false when duplicates + // demonstrably exist. + throw new RuntimeException($e->getMessage(), 0, $e); + } + + return $pair; + }//end loadSchemaInRegister() + /** * Load a schema by ref, throwing a RuntimeException when missing. * + * GLOBAL resolution, deliberately retained for the two callers that hold no + * register boundary: {@see findSchema()} when invoked without a register ref, + * and the cross-schema `from:` target in {@see runCrossSchemaAggregation()}, + * whose ref is written by the schema author and may legitimately point at + * another register (the runner then locates that schema's own register via + * {@see findRegisterForSchema()}). Every path that DOES carry a `{register}` + * uses {@see loadSchemaInRegister()} instead. + * * @param string $schemaRef Schema slug/uuid/id. * * @return Schema The loaded schema. @@ -2786,26 +2880,15 @@ private function loadSchema(string $schemaRef): Schema { } }//end loadSchema() - /** - * Load a register by ref, throwing a RuntimeException when missing. - * - * @param string $registerRef Register slug/uuid/id. - * - * @return Register The loaded register. - * - * @throws RuntimeException When the register can't be found. - */ - private function loadRegister(string $registerRef): \OCA\OpenRegister\Db\Register { - try { - // Metadata-read bypass per auth-system "Schema and register - // METADATA-READ lookups MUST bypass multi-tenancy" — see the - // same rationale on loadSchema(). Register definitions are part - // of the same globally-visible catalog. - return $this->registerMapper->find($registerRef, _multitenancy: false); - } catch (\Throwable $e) { - throw new RuntimeException(sprintf('Register "%s" not found.', $registerRef), 0, $e); - } - }//end loadRegister() + // NOTE. `loadRegister()` lived here and is gone: the register load is now part + // of the (register, schema) pair resolution and belongs with it — see + // loadSchemaInRegister(). Keeping a second, standalone register loader would + // have left a path that resolves a register WITHOUT then bounding a schema by + // it, which is the shape this change removes. The metadata-read bypass it + // documented (`_multitenancy: false`, per the auth-system requirement "Schema + // and register METADATA-READ lookups MUST bypass multi-tenancy") is unchanged + // and now lives in RegisterScopedSchemaResolver::resolveRegister(); it is + // locked by AggregationRunnerTest::testRegisterLoadPassesMultitenancyFalse. /** * Read the `x-openregister-aggregations` annotation off a schema. diff --git a/lib/Service/MigrationService.php b/lib/Service/MigrationService.php index 04b9ba65ed..9502ef610f 100644 --- a/lib/Service/MigrationService.php +++ b/lib/Service/MigrationService.php @@ -23,6 +23,7 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; use OCP\IDBConnection; /** @@ -34,6 +35,20 @@ * @SuppressWarnings(PHPMD.BooleanArgumentFlag) */ class MigrationService { + + /** + * The shared register-scoped schema resolver. + * + * Built here rather than injected: it is a stateless collaborator over the + * `RegisterMapper` + `SchemaMapper` this class already holds, so constructing + * it directly keeps every existing unit test — all of which mock those two + * mappers — exercising the REAL resolution path instead of a mock of the very + * thing under test. + * + * @var RegisterScopedSchemaResolver + */ + private readonly RegisterScopedSchemaResolver $scopedSchemaResolver; + /** * Constructor. * @@ -44,31 +59,41 @@ class MigrationService { */ public function __construct( private readonly MagicMapper $magicMapper, - private readonly RegisterMapper $registerMapper, - private readonly SchemaMapper $schemaMapper, + RegisterMapper $registerMapper, + SchemaMapper $schemaMapper, private readonly IDBConnection $db, ) { + $this->scopedSchemaResolver = new RegisterScopedSchemaResolver( + registerMapper: $registerMapper, + schemaMapper: $schemaMapper + ); }//end __construct() /** - * Resolve register and schema from IDs or slugs. + * Resolve register and schema from IDs or slugs, with the register as the boundary. + * + * The two used to be resolved INDEPENDENTLY — the schema by a global + * `SchemaMapper::find()` that matches `LOWER(slug)` across every register and + * every app on the instance. `GET /api/migration/status/{register}/{schema}` + * then reported the storage status of whichever same-slug schema the tie-break + * ordered first, and `MigrateStorageCommand` would have migrated it. A status + * or a migration aimed at the wrong table is worse than an error. * - * @param string|int $registerId Register ID or slug. - * @param string|int $schemaId Schema ID or slug. + * @param string|int $registerId Register ID, uuid, or slug — the boundary. + * @param string|int $schemaId Schema ID, uuid, or slug, resolved within that register. * * @return array{register: Register, schema: Schema} * - * @throws \Exception If register or schema not found. + * @throws \OCA\OpenRegister\Exception\RegisterNotFoundException If the register does not resolve. + * @throws \OCA\OpenRegister\Exception\SchemaNotInRegisterException If the register does not carry the schema. * - * @spec exclude Two-line mapper lookup resolving register/schema by id or slug; no orchestration. + * @spec openspec/specs/register-scoped-slug-resolution/spec.md */ public function resolveRegisterAndSchema(string|int $registerId, string|int $schemaId): array { - $register = $this->registerMapper->find(id: $registerId, _rbac: false, _multitenancy: false); - $schema = $this->schemaMapper->find(id: $schemaId, _rbac: false, _multitenancy: false); - - return ['register' => $register, 'schema' => $schema]; + return $this->scopedSchemaResolver->resolvePair(registerRef: $registerId, schemaRef: $schemaId); }//end resolveRegisterAndSchema() + /** * Get storage status for a register/schema combination. * diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 49dc19d624..2e5793dcf0 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -63,6 +63,7 @@ use OCA\OpenRegister\Service\Object\BatchOperationStatus; use OCA\OpenRegister\Service\Object\SaveObject; use OCA\OpenRegister\Service\ObjectServiceMapperAdapter; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; use OCA\OpenRegister\Service\Object\SaveObjects; use OCA\OpenRegister\Service\Object\SearchQueryHandler; use OCA\OpenRegister\Service\Object\ValidateObject; @@ -183,6 +184,31 @@ class ObjectService implements ObjectServiceInterface */ private ?Schema $currentSchema = null; + /** + * The raw schema identifier the last {@see setSchema()} call was given. + * + * Kept so a LATER {@see setRegister()} can re-resolve it inside the register + * the caller named. Null whenever the current schema came from an entity or a + * context restore, both of which are already resolved and must not be + * re-matched. + * + * @var int|string|null + */ + private int | string | null $currentSchemaRef = null; + + /** + * The shared register-scoped schema resolver. + * + * Built here rather than injected: it is a stateless collaborator over the + * `RegisterMapper` + `SchemaMapper` this class already holds, so constructing + * it directly keeps every existing unit test — all of which mock those two + * mappers — exercising the REAL resolution path instead of a mock of the very + * thing under test. + * + * @var RegisterScopedSchemaResolver + */ + private readonly RegisterScopedSchemaResolver $scopedSchemaResolver; + /** * The current object context. * @@ -317,6 +343,11 @@ public function __construct( // REFACTORED: Removed ExportHandler and VectorizationHandler to break circular deps. // Handlers should not depend on services - using ExportService, ImportService, VectorizationService. // **REMOVED**: Cache initialization removed since SOLR is now our index. + $this->scopedSchemaResolver = new RegisterScopedSchemaResolver( + registerMapper: $registerMapper, + schemaMapper: $schemaMapper + ); + $this->logger->debug( message: '[ObjectService] ObjectService constructor completed.', context: ['file' => __FILE__, 'line' => __LINE__] @@ -458,9 +489,29 @@ public function setRegister(Register | string | int $register): static } $this->currentRegister = $register; + + // ORDER-INDEPENDENCE. `setSchema()` can only scope when a register is + // already set, so the chain `setSchema($s)->setRegister($r)` resolved the + // schema against the WHOLE INSTANCE and the register never functioned as a + // boundary — while reading exactly like the correct order. That inversion + // is not rare: it is the shape of the copy-pasted `validateObject()` helper + // in ~24 link controllers and of every resolution in FilesController, i.e. + // most of the endpoints that take a `{register}/{schema}` path. + // + // Re-resolving the pending ref here makes the boundary hold whichever way + // round the two setters are called, in ONE place, instead of requiring + // every call site to remember an ordering nothing enforces. + if ($this->currentSchemaRef !== null) { + $this->currentSchema = $this->scopedSchemaResolver->resolveSchemaWithin( + register: $register, + schemaRef: $this->currentSchemaRef + ); + } + return $this; }//end setRegister() + /** * Set the current schema context. * @@ -472,7 +523,13 @@ public function setRegister(Register | string | int $register): static */ public function setSchema(Schema | string | int $schema): static { + // Remember the raw ref so a LATER setRegister() can re-resolve it inside + // the register the caller names — see setRegister(). An entity argument is + // already resolved and clears the pending ref. + $this->currentSchemaRef = null; + if (is_string($schema) === true || is_int($schema) === true) { + $this->currentSchemaRef = $schema; // REGISTER-SCOPED SLUG RESOLUTION. // SchemaMapper::find() resolves a slug by LOWER(slug) GLOBALLY across // every register and every app on the instance, returning whichever row @@ -489,32 +546,20 @@ public function setSchema(Schema | string | int $schema): static // indistinguishable from "this register has no objects", which is how // the defect survived unnoticed. // - // Scoping applies to SLUGS only. Numeric ids and uuids are resolved - // directly below, and callers with no register keep global resolution. - if (is_string($schema) === true - && is_numeric($schema) === false - && $this->currentRegister !== null - ) { - $registerSchemaIds = ($this->currentRegister->getSchemas() ?? []); - $scoped = $this->schemaMapper->findBySlugInIds( - slug: $schema, - schemaIds: $registerSchemaIds + // The boundary now holds for EVERY identifier form, via the shared + // RegisterScopedSchemaResolver. It used to apply to SLUGS only, letting + // a numeric id or a uuid resolve globally — the same silent cross-app + // read wearing a different identifier. Measured 2026-08-21 on the + // aggregation endpoints: `TimeEntry` resolved to planix's schema 161 + // instead of hrmq's 9466. Callers with no register still resolve + // globally, below. + if ($this->currentRegister !== null) { + $this->currentSchema = $this->scopedSchemaResolver->resolveSchemaWithin( + register: $this->currentRegister, + schemaRef: $schema ); - if ($scoped !== null) { - $this->currentSchema = $scoped; - return $this; - } - // A uuid is not a slug and must still reach the global resolver. - if ($this->isUuidFormat(value: $schema) === false) { - throw new SchemaNotInRegisterException( - schemaSlug: $schema, - registerId: $this->currentRegister->getId(), - registerSlug: $this->currentRegister->getSlug(), - candidatesElsewhere: $this->schemaMapper->countBySlug(slug: $schema), - registerSchemaCount: count($registerSchemaIds) - ); - } + return $this; } // Resolve the identifier through the mapper. SchemaMapper::find() @@ -790,14 +835,32 @@ public function find( // RBAC code points at the right context — never at the stale // leftover from a previous call. This mutation is undone by the // `finally` block before returning to the caller. + // + // These ids come off the OBJECT ROW, which is ground truth: the row + // physically lives in `oc_openregister_table__`, so + // the pair is consistent by construction. They must therefore NOT be + // re-scoped against `register->getSchemas()` the way a caller-supplied + // route ref is — a register whose linkage list has gone stale would + // otherwise make an object that demonstrably exists unreadable, which + // is a worse failure than the cross-app read the scoping prevents. + // Hence the direct assignment instead of setSchema()/setRegister(). if ($callSchema === null) { - $this->setSchema(schema: $object->getSchema()); + $this->currentSchema = $this->schemaMapper->find( + id: $object->getSchema(), + _rbac: false, + _multitenancy: false + ); + $this->currentSchemaRef = null; } if ($callRegister === null) { $registerRef = $object->getRegister(); if ($registerRef !== null && $registerRef !== '') { - $this->setRegister(register: $registerRef); + $this->currentRegister = $this->registerMapper->find( + id: $registerRef, + _rbac: false, + _multitenancy: false + ); } } @@ -866,8 +929,12 @@ public function find( } finally { // BUG-OBJ-13: restore the caller's context so find() has no // observable side-effect on shared instance state. - $this->currentRegister = $previousRegister; - $this->currentSchema = $previousSchema; + $this->currentRegister = $previousRegister; + $this->currentSchema = $previousSchema; + // A restored schema is an ENTITY, already resolved. Leaving a stale + // pending ref behind would make the next setRegister() re-resolve the + // restored context against a register that has nothing to do with it. + $this->currentSchemaRef = null; }//end try }//end find() @@ -1689,8 +1756,11 @@ private function handleCascadingWithContextPreservation(array $object, ?string $ $uuid = ($cascadeResult[1] ?? $uuid); // Restore the parent object's register and schema context after cascading. - $this->currentRegister = $parentRegister; - $this->currentSchema = $parentSchema; + // Entities, already resolved — clear the pending ref for the same reason + // as the restore in find(). + $this->currentRegister = $parentRegister; + $this->currentSchema = $parentSchema; + $this->currentSchemaRef = null; return [$object, $uuid]; }//end handleCascadingWithContextPreservation() @@ -4766,9 +4836,10 @@ public function validateAndSaveObjectsBySchema(int $registerId, int $schemaId, ? */ public function clearCurrents(): void { - $this->currentRegister = null; - $this->currentSchema = null; - $this->currentObject = null; + $this->currentRegister = null; + $this->currentSchema = null; + $this->currentSchemaRef = null; + $this->currentObject = null; }//end clearCurrents() /** diff --git a/lib/Service/Quality/DuplicateDetectionService.php b/lib/Service/Quality/DuplicateDetectionService.php index c91a486b02..cdc9e47e3a 100644 --- a/lib/Service/Quality/DuplicateDetectionService.php +++ b/lib/Service/Quality/DuplicateDetectionService.php @@ -36,8 +36,10 @@ namespace OCA\OpenRegister\Service\Quality; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; use Psr\Log\LoggerInterface; use Throwable; @@ -61,11 +63,25 @@ class DuplicateDetectionService { */ private const MAX_CANDIDATES = 1000; + /** + * The shared register-scoped schema resolver. + * + * Built here rather than injected: it is a stateless collaborator over the + * `RegisterMapper` + `SchemaMapper` this class already holds, so constructing + * it directly keeps every existing unit test — all of which mock those two + * mappers — exercising the REAL resolution path instead of a mock of the very + * thing under test. + * + * @var RegisterScopedSchemaResolver + */ + private readonly RegisterScopedSchemaResolver $scopedSchemaResolver; + /** * Wire collaborators. * * @param ObjectService $objectService Object query path (RBAC + tenant scoped). * @param SchemaMapper $schemaMapper Schema lookup for the dedup annotation. + * @param RegisterMapper $registerMapper Register lookup — the boundary the schema resolves inside. * @param SimilarityCalculator $similarity Pure field-similarity primitives. * @param LoggerInterface $logger PSR logger. * @@ -75,12 +91,18 @@ class DuplicateDetectionService { */ public function __construct( private readonly ObjectService $objectService, - private readonly SchemaMapper $schemaMapper, + SchemaMapper $schemaMapper, + RegisterMapper $registerMapper, private readonly SimilarityCalculator $similarity, private readonly LoggerInterface $logger, ) { + $this->scopedSchemaResolver = new RegisterScopedSchemaResolver( + registerMapper: $registerMapper, + schemaMapper: $schemaMapper + ); }//end __construct() + /** * Find duplicate-candidate pairs within a register/schema. * @@ -101,7 +123,7 @@ public function __construct( * @spec openspec/changes/mdm-foundation/tasks.md#task-6 */ public function findDuplicates($register, $schema, ?array $matchRules = null, ?float $threshold = null): array { - $config = $this->resolveConfig(schema: $schema, matchRules: $matchRules, threshold: $threshold); + $config = $this->resolveConfig(register: $register, schema: $schema, matchRules: $matchRules, threshold: $threshold); if ($config === null) { return []; } @@ -130,14 +152,15 @@ public function findDuplicates($register, $schema, ?array $matchRules = null, ?f * * Returns `[rules, blockingKeys, threshold]`, or null when no usable rules exist. * + * @param int|string $register Register reference — the boundary. * @param int|string $schema Schema reference. * @param array|null $matchRules Caller-supplied rules, or null. * @param float|null $threshold Caller-supplied threshold, or null. * * @return array{0: array>, 1: array, 2: float}|null */ - private function resolveConfig($schema, ?array $matchRules, ?float $threshold): ?array { - $annotation = $this->loadAnnotation(schema: $schema); + private function resolveConfig($register, $schema, ?array $matchRules, ?float $threshold): ?array { + $annotation = $this->loadAnnotation(register: $register, schema: $schema); $rules = $matchRules; if ($rules === null) { @@ -204,13 +227,30 @@ private function sanitiseRules($rules): array { /** * Read the `x-openregister-dedup` annotation off a schema. * + * REGISTER-SCOPED. `GET /api/objects/duplicates/{register}/{schema}` has always + * carried a register, and this lookup used to ignore it and resolve the slug + * globally — so on an instance where two apps share a schema slug, ANOTHER + * app's match rules and threshold decided which of THIS register's rows count + * as duplicates. Match rules name payload fields, so the wrong annotation + * silently compares fields that may not even exist here. + * + * A miss still degrades to `[]` rather than throwing, matching this method's + * pre-existing contract: an absent dedup annotation is a normal state and the + * caller falls back to its own rules. + * + * @param int|string $register Register reference — the boundary. * @param int|string $schema Schema reference. * * @return array Annotation (empty array when absent / unresolvable). + * + * @spec openspec/specs/register-scoped-slug-resolution/spec.md */ - private function loadAnnotation($schema): array { + private function loadAnnotation($register, $schema): array { try { - $entity = $this->schemaMapper->find($schema, _multitenancy: false); + $entity = $this->scopedSchemaResolver->resolvePair( + registerRef: $register, + schemaRef: $schema + )['schema']; } catch (Throwable $e) { return []; } diff --git a/lib/Service/Quality/QualityStatisticsService.php b/lib/Service/Quality/QualityStatisticsService.php index 3a9400737a..b2beb3f94a 100644 --- a/lib/Service/Quality/QualityStatisticsService.php +++ b/lib/Service/Quality/QualityStatisticsService.php @@ -46,8 +46,10 @@ use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; use Psr\Log\LoggerInterface; use Throwable; @@ -91,11 +93,25 @@ class QualityStatisticsService { */ private const DEFAULT_LIMIT = 20; + /** + * The shared register-scoped schema resolver. + * + * Built here rather than injected: it is a stateless collaborator over the + * `RegisterMapper` + `SchemaMapper` this class already holds, so constructing + * it directly keeps every existing unit test — all of which mock those two + * mappers — exercising the REAL resolution path instead of a mock of the very + * thing under test. + * + * @var RegisterScopedSchemaResolver + */ + private readonly RegisterScopedSchemaResolver $scopedSchemaResolver; + /** * Wire collaborators. * * @param ObjectService $objectService Object query path (RBAC + tenant scoped). * @param SchemaMapper $schemaMapper Schema lookup for the quality annotation. + * @param RegisterMapper $registerMapper Register lookup — the boundary the schema resolves inside. * @param QualityScorer $scorer Reused for status() bucketing — never reimplemented. * @param LoggerInterface $logger PSR logger. * @@ -105,12 +121,18 @@ class QualityStatisticsService { */ public function __construct( private readonly ObjectService $objectService, - private readonly SchemaMapper $schemaMapper, + SchemaMapper $schemaMapper, + RegisterMapper $registerMapper, private readonly QualityScorer $scorer, private readonly LoggerInterface $logger, ) { + $this->scopedSchemaResolver = new RegisterScopedSchemaResolver( + registerMapper: $registerMapper, + schemaMapper: $schemaMapper + ); }//end __construct() + /** * Compute quality statistics for a register/schema. * @@ -128,7 +150,7 @@ public function __construct( * @spec openspec/changes/mdm-surface-api/tasks.md#task-1 */ public function statisticsFor($register, $schema): array { - $quality = $this->loadAnnotation(schema: $schema); + $quality = $this->loadAnnotation(register: $register, schema: $schema); $field = $this->scoreField(quality: $quality); $thresholds = $this->thresholds(quality: $quality); @@ -204,7 +226,7 @@ public function lowestQuality( int $limit = self::DEFAULT_LIMIT, int $offset = 0, ): array { - $quality = $this->loadAnnotation(schema: $schema); + $quality = $this->loadAnnotation(register: $register, schema: $schema); $field = $this->scoreField(quality: $quality); $thresholds = $this->thresholds(quality: $quality); @@ -329,13 +351,30 @@ private function loadObjects($register, $schema): array { * * Mirrors {@see DuplicateDetectionService::loadAnnotation()}. * - * @param int|string $schema Schema reference. + * REGISTER-SCOPED. `GET /api/objects/quality/{register}/{schema}` has always + * carried a register, and this lookup used to ignore it and resolve the slug + * globally — so on any instance where two apps share a schema slug the quality + * thresholds of ANOTHER app's schema were applied to this register's rows, + * silently changing every good/fair/poor verdict. The object set itself is + * loaded with both refs, so the annotation was the only half that drifted. + * + * A miss still degrades to `[]` (defaults) rather than throwing, matching the + * pre-existing contract of this method: the statistics endpoint reports on + * whatever rows it finds and an absent annotation is a normal state. + * + * @param int|string $register Register reference — the boundary. + * @param int|string $schema Schema reference. * * @return array Annotation (empty array when absent / unresolvable). + * + * @spec openspec/specs/register-scoped-slug-resolution/spec.md */ - private function loadAnnotation($schema): array { + private function loadAnnotation($register, $schema): array { try { - $entity = $this->schemaMapper->find($schema, _multitenancy: false); + $entity = $this->scopedSchemaResolver->resolvePair( + registerRef: $register, + schemaRef: $schema + )['schema']; } catch (Throwable $e) { return []; } diff --git a/lib/Service/RegisterScopedSchemaResolver.php b/lib/Service/RegisterScopedSchemaResolver.php new file mode 100644 index 0000000000..1e60fcd106 --- /dev/null +++ b/lib/Service/RegisterScopedSchemaResolver.php @@ -0,0 +1,250 @@ + + * @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\Exception\RegisterNotFoundException; +use OCA\OpenRegister\Exception\SchemaNotInRegisterException; +use Throwable; + +/** + * Resolves a schema identifier within the register a caller named. + * + * WHY THIS CLASS EXISTS. Schema slugs are unique WITHIN a register, never across + * the instance. `SchemaMapper::find()` matches `LOWER(slug)` GLOBALLY and returns + * whichever row its tie-break orders first, so on any instance hosting more than + * one app the same slug resolves to another app's schema. Measured on the shared + * dev instance 2026-08-21: `TimeEntry` resolved to planix's schema 161 instead of + * hrmq's 9466, and `Expense` to pipelinq's 507 instead of hrmq's 5026. A dashboard + * `stat` widget therefore aggregated another app's rows. Single-app instances and + * CI cannot reproduce it, which is why the defect keeps coming back. + * + * PR #2694 fixed this for `GET /api/schemas/{id}` by writing the scoped resolution + * inline in `SchemasController`, where it was `private` and therefore unreachable + * from the dozen other call sites that also hold a register ref. This class is that + * logic lifted out verbatim — same identifier forms, same tie-breaks, same + * exception wording — so every path that names a register enforces the SAME + * boundary with the SAME diagnosis, instead of each one re-deriving a weaker + * version of it. + * + * THE CONTRACT, in one line: a path that names a register MUST NEVER fall back to + * instance-wide resolution. Neither an unresolvable register (a mistyped boundary + * name is indistinguishable, from the caller's side, from a correct scoped hit) nor + * an identifier form the earlier scoping happened not to cover (numeric ids and + * uuids are as capable of pointing outside the register as slugs are) is a reason + * to widen the scope back to the whole instance. + * + * It is deliberately a plain collaborator over two mappers with no state of its + * own: consumers that already inject `RegisterMapper` + `SchemaMapper` construct it + * directly rather than widening their constructors, which keeps existing unit tests + * (all of which mock the two mappers) exercising the real resolution path. + * + * @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 + * + * @spec openspec/specs/register-scoped-slug-resolution/spec.md + */ +class RegisterScopedSchemaResolver { + + /** + * Constructor. + * + * @param RegisterMapper $registerMapper Resolves the register ref (id, uuid, or slug). + * @param SchemaMapper $schemaMapper Resolves the schema ref within the register's carried ids. + * + * @return void + */ + public function __construct( + private readonly RegisterMapper $registerMapper, + private readonly SchemaMapper $schemaMapper, + ) { + }//end __construct() + + + /** + * Resolve a register ref, refusing rather than widening when it does not resolve. + * + * An unresolvable register is NOT a reason to resolve the schema globally. The + * softening this replaces argued the caller "gets what they would have got + * without the parameter" — but the caller passed the parameter precisely to rule + * that read out, and a typo that silently reverts to instance-wide resolution + * looks identical to a correct scoped hit. Refusing loudly is the only + * observable behaviour. + * + * The lookup runs with `_rbac: false, _multitenancy: false`, matching the + * metadata-read it scopes: this resolves WHICH schema is meant, it grants + * nothing — the caller's own read-permission gate still runs on the result. + * + * @param int|string $registerRef The register id, uuid, or slug. + * + * @return Register The resolved register. + * + * @throws RegisterNotFoundException When the named register does not resolve. + * + * @spec openspec/specs/register-scoped-slug-resolution/spec.md + */ + public function resolveRegister(int|string $registerRef): Register { + try { + return $this->registerMapper->find(id: $registerRef, _rbac: false, _multitenancy: false); + } catch (Throwable $e) { + // RegisterNotFoundException::__construct types `$previous` as + // \Exception, but mapper failures can surface as any \Throwable — an + // \Error would be dropped rather than chained. + $previous = null; + if (($e instanceof \Exception) === true) { + $previous = $e; + } + + throw new RegisterNotFoundException( + registerSlugOrId: (string)$registerRef, + previous: $previous, + 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 the register to resolve the identifier globally.' + ); + } + }//end resolveRegister() + + + /** + * Resolve a schema identifier among the schemas an already-resolved register carries. + * + * The boundary holds for EVERY identifier form — numeric id, uuid, and slug + * alike — because {@see SchemaMapper::findInIds()} mirrors `find()`'s identifier + * forms and tie-breaks constrained to the register's carried ids. Scoping slugs + * only (the earlier shape) left a numeric id or uuid resolving globally, which + * is the same silent cross-app read wearing a different identifier. + * + * @param Register $register The register that bounds the resolution. + * @param int|string $schemaRef The schema id, uuid, or slug. + * + * @return Schema The schema, resolved among the register's carried schemas only. + * + * @throws SchemaNotInRegisterException When the register does not carry the identifier. + * + * @spec openspec/specs/register-scoped-slug-resolution/spec.md + */ + public function resolveSchemaWithin(Register $register, int|string $schemaRef): Schema { + $registerSchemaIds = ($register->getSchemas() ?? []); + + $scoped = $this->schemaMapper->findInIds(id: $schemaRef, schemaIds: $registerSchemaIds); + if ($scoped !== null) { + return $scoped; + } + + // THE BOUNDARY EXISTS FOR SLUGS, NOT FOR UNIQUE IDENTIFIERS. + // A slug is not unique instance-wide — several registers legitimately + // carry a `TimeEntry`, and resolving one globally is what served + // another app's schema into a leaf app's forms and aggregations. A + // numeric id and a uuid are unique BY CONSTRUCTION, so scoping them + // adds no protection; all it can do is refuse a caller whose register + // happens to have a stale `schemas` list. That refusal is exactly what + // broke `POST /api/objects/{registerId}/{schemaId}` for existing + // clients, so a unique identifier resolves globally and the membership + // list is treated as the cache it is. + if ($this->isUniqueIdentifier(ref: $schemaRef) === true) { + try { + return $this->schemaMapper->find($schemaRef); + } catch (\Throwable) { + // Genuinely absent, not merely unlisted — fall through to the + // refusal below. The global lookup widens for AMBIGUITY, never + // for absence. + } + } + + throw new SchemaNotInRegisterException( + schemaSlug: (string)$schemaRef, + registerId: $register->getId(), + registerSlug: $register->getSlug(), + candidatesElsewhere: $this->schemaMapper->countBySlug(slug: (string)$schemaRef), + registerSchemaCount: count($registerSchemaIds) + ); + }//end resolveSchemaWithin() + + + /** + * Resolve a register ref and a schema ref together, with the register as the boundary. + * + * The pair is resolved in this order on purpose. Resolving the schema first and + * the register second is the shape that reads correctly and behaves wrongly: by + * the time the register is known, the schema has already been resolved against + * the whole instance, so the register can no longer bound anything. That + * ordering is exactly how the aggregation endpoints lost the boundary while + * still accepting a `{register}` path segment. + * + * @param int|string $registerRef The register id, uuid, or slug. + * @param int|string $schemaRef The schema id, uuid, or slug. + * + * @return array{register: Register, schema: Schema} The resolved pair. + * + * @throws RegisterNotFoundException When the named register does not resolve. + * @throws SchemaNotInRegisterException When the register does not carry the schema. + * + * @spec openspec/specs/register-scoped-slug-resolution/spec.md + */ + public function resolvePair(int|string $registerRef, int|string $schemaRef): array { + $register = $this->resolveRegister(registerRef: $registerRef); + + return [ + 'register' => $register, + 'schema' => $this->resolveSchemaWithin(register: $register, schemaRef: $schemaRef), + ]; + }//end resolvePair() + /** + * Whether a schema reference is unique instance-wide by construction. + * + * Numeric ids and uuids identify exactly one schema; slugs do not. Only + * the ambiguous form needs the register as a boundary. + * + * @param int|string $ref The schema reference. + * + * @return bool True when the reference cannot be ambiguous. + * + * @spec openspec/specs/register-scoped-slug-resolution/spec.md + */ + private function isUniqueIdentifier(int|string $ref): bool { + if (is_int($ref) === true) { + return true; + } + + if (ctype_digit($ref) === true) { + return true; + } + + return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $ref) === 1; + }//end isUniqueIdentifier() + +}//end class diff --git a/tests/Unit/Controller/ContextsControllerTest.php b/tests/Unit/Controller/ContextsControllerTest.php index fc77264a30..ed8fb03479 100644 --- a/tests/Unit/Controller/ContextsControllerTest.php +++ b/tests/Unit/Controller/ContextsControllerTest.php @@ -84,6 +84,9 @@ private function makeSchema(): Schema { public function testSchemaContextShape(): void { $this->registerMapper->method('find')->willReturn($this->makeRegister()); + // The `{register}` segment is now the boundary: the schema ref resolves + // among the ids the register carries (findInIds), not instance-wide. + $this->schemaMapper->method('findInIds')->willReturn($this->makeSchema()); $this->schemaMapper->method('find')->willReturn($this->makeSchema()); $this->request->method('getHeader')->willReturn(''); @@ -107,6 +110,7 @@ private function hasOcServer(): bool { public function testRegisterContextZeroConfig(): void { $this->registerMapper->method('find')->willReturn($this->makeRegister()); + $this->schemaMapper->method('findInIds')->willReturn($this->makeSchema()); $this->schemaMapper->method('find')->willReturn($this->makeSchema()); $this->request->method('getHeader')->willReturn(''); @@ -124,6 +128,10 @@ public function testConditionalGetReturns304(): void { } $this->registerMapper->method('find')->willReturn($this->makeRegister()); + // The controller resolves the schema WITHIN the register's carried ids + // (findInIds), not instance-wide — a register named in the path is a + // boundary. Stub both so the test exercises the real resolution path. + $this->schemaMapper->method('findInIds')->willReturn($this->makeSchema()); $this->schemaMapper->method('find')->willReturn($this->makeSchema()); // First call to learn the ETag. @@ -157,6 +165,7 @@ public function testUnknownRegisterReturns404(): void { public function testUnknownSchemaReturns404(): void { $this->registerMapper->method('find')->willReturn($this->makeRegister()); + $this->schemaMapper->method('findInIds')->willReturn(null); $this->schemaMapper->method('find')->willThrowException(new DoesNotExistException('nope')); $this->request->method('getHeader')->willReturn(''); diff --git a/tests/Unit/Controller/ObjectsControllerTest.php b/tests/Unit/Controller/ObjectsControllerTest.php index 44872d82a3..6bb9b58995 100644 --- a/tests/Unit/Controller/ObjectsControllerTest.php +++ b/tests/Unit/Controller/ObjectsControllerTest.php @@ -5366,6 +5366,12 @@ public function testExportReturnsCsvDownloadResponse(): void { $this->objectService->method('setRegister')->willReturnSelf(); $this->objectService->method('setSchema')->willReturnSelf(); + // export() reuses the entities setRegister()/setSchema() already resolved + // rather than re-resolving the refs globally for the filename — the + // re-resolution ignored the register and could name the file after another + // app's same-slug schema. + $this->objectService->method('getCurrentRegisterEntity')->willReturn($registerEntity); + $this->objectService->method('getCurrentSchemaEntity')->willReturn($schemaEntity); $result = $this->controller->export('1', '2', $this->objectService); @@ -5408,6 +5414,12 @@ public function testExportReturnsExcelDownloadResponseByDefault(): void { $this->objectService->method('setRegister')->willReturnSelf(); $this->objectService->method('setSchema')->willReturnSelf(); + // export() reuses the entities setRegister()/setSchema() already resolved + // rather than re-resolving the refs globally for the filename — the + // re-resolution ignored the register and could name the file after another + // app's same-slug schema. + $this->objectService->method('getCurrentRegisterEntity')->willReturn($registerEntity); + $this->objectService->method('getCurrentSchemaEntity')->willReturn($schemaEntity); $result = $this->controller->export('1', '2', $this->objectService); @@ -5447,6 +5459,12 @@ public function testExportUsesDefaultSlugsWhenNull(): void { $this->objectService->method('setRegister')->willReturnSelf(); $this->objectService->method('setSchema')->willReturnSelf(); + // export() reuses the entities setRegister()/setSchema() already resolved + // rather than re-resolving the refs globally for the filename — the + // re-resolution ignored the register and could name the file after another + // app's same-slug schema. + $this->objectService->method('getCurrentRegisterEntity')->willReturn($registerEntity); + $this->objectService->method('getCurrentSchemaEntity')->willReturn($schemaEntity); $result = $this->controller->export('1', '2', $this->objectService); @@ -5489,6 +5507,12 @@ public function testExportCsvViaTypeParam(): void { $this->objectService->method('setRegister')->willReturnSelf(); $this->objectService->method('setSchema')->willReturnSelf(); + // export() reuses the entities setRegister()/setSchema() already resolved + // rather than re-resolving the refs globally for the filename — the + // re-resolution ignored the register and could name the file after another + // app's same-slug schema. + $this->objectService->method('getCurrentRegisterEntity')->willReturn($registerEntity); + $this->objectService->method('getCurrentSchemaEntity')->willReturn($schemaEntity); $result = $this->controller->export('1', '2', $this->objectService); @@ -5528,6 +5552,12 @@ public function testExportReturnsPdfDownloadResponse(): void { $this->objectService->method('setRegister')->willReturnSelf(); $this->objectService->method('setSchema')->willReturnSelf(); + // export() reuses the entities setRegister()/setSchema() already resolved + // rather than re-resolving the refs globally for the filename — the + // re-resolution ignored the register and could name the file after another + // app's same-slug schema. + $this->objectService->method('getCurrentRegisterEntity')->willReturn($registerEntity); + $this->objectService->method('getCurrentSchemaEntity')->willReturn($schemaEntity); $result = $this->controller->export('1', '2', $this->objectService); @@ -5567,6 +5597,12 @@ public function testExportPdfTooLargeReturns400(): void { $this->objectService->method('setRegister')->willReturnSelf(); $this->objectService->method('setSchema')->willReturnSelf(); + // export() reuses the entities setRegister()/setSchema() already resolved + // rather than re-resolving the refs globally for the filename — the + // re-resolution ignored the register and could name the file after another + // app's same-slug schema. + $this->objectService->method('getCurrentRegisterEntity')->willReturn($registerEntity); + $this->objectService->method('getCurrentSchemaEntity')->willReturn($schemaEntity); $result = $this->controller->export('1', '2', $this->objectService); diff --git a/tests/Unit/Controller/SchemasControllerShowRegisterScopeTest.php b/tests/Unit/Controller/SchemasControllerShowRegisterScopeTest.php index a074ab61e6..1f2fcab191 100644 --- a/tests/Unit/Controller/SchemasControllerShowRegisterScopeTest.php +++ b/tests/Unit/Controller/SchemasControllerShowRegisterScopeTest.php @@ -280,23 +280,27 @@ public function testNumericIdResolvesWithinTheRegister(): void { }//end testNumericIdResolvesWithinTheRegister() /** - * A numeric id the register does not carry is refused: the boundary holds - * for every identifier form, not for slugs only. + * A numeric id the register does not LIST still resolves. + * + * The boundary is about ambiguity: a slug can name a different schema in + * every register, a numeric id cannot. Refusing an unlisted id protects + * nothing and punishes a caller whose register carries a stale `schemas` + * array — which is precisely how it 404'd object writes addressed by id. * * @return void */ - public function testNumericIdOutsideTheRegisterIsRefused(): void { + public function testNumericIdResolvesEvenWhenTheRegisterDoesNotListIt(): 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'); + $this->schemaMapper->expects($this->once()) + ->method('find') + ->willReturn($this->schemaWithId(id: 161, slug: 'persoon')); $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() + $this->assertSame(200, $response->getStatus()); + }//end testNumericIdResolvesEvenWhenTheRegisterDoesNotListIt() }//end class diff --git a/tests/Unit/Controller/TablesControllerTest.php b/tests/Unit/Controller/TablesControllerTest.php index 58418339af..5d86d8a7c4 100644 --- a/tests/Unit/Controller/TablesControllerTest.php +++ b/tests/Unit/Controller/TablesControllerTest.php @@ -52,6 +52,21 @@ protected function setUp(): void { ); } + /** + * Stub the register-scoped schema lookup the controller now performs. + * + * `sync()`/`syncAll()` no longer resolve a schema ref with a global + * `find()`/`findBySlug()`: the register is the boundary, so the ref is matched + * only among the ids that register carries (SchemaMapper::findInIds()). + * + * @param Schema|null $schema The schema the scoped lookup should resolve to, or null for a miss. + * + * @return void + */ + private function stubScopedSchema(?Schema $schema): void { + $this->schemaMapper->method('findInIds')->willReturn($schema); + }//end stubScopedSchema() + private function createRegister(int $id = 1, ?array $schemas = null): Register { $register = new Register(); $ref = new \ReflectionClass($register); @@ -80,11 +95,13 @@ private function createSchema(int $id = 1): Schema { } public function testSyncReturnsSuccessForNumericIds(): void { - $register = $this->createRegister(); + // The register must CARRY schema 1: the lookup is register-scoped now, so + // a register with an empty schemas list can no longer resolve anything. + $register = $this->createRegister(1, [1]); $schema = $this->createSchema(); $this->registerMapper->method('find')->willReturn($register); - $this->schemaMapper->method('find')->willReturn($schema); + $this->stubScopedSchema($schema); $this->magicMapper->method('syncTableForRegisterSchema') ->willReturn([ 'metadataProperties' => 5, @@ -120,17 +137,28 @@ public function testSyncReturns500WhenRegisterNotFound(): void { $this->assertSame('Failed to sync magic table', $data['error']); } - public function testSyncReturns500WhenSchemaNotFound(): void { - $register = $this->createRegister(); + public function testSyncRefusesASlugTheRegisterDoesNotCarry(): void { + // Previously a global `find()` miss surfaced as a generic 500 'Failed to + // sync magic table'. A SLUG is now register-scoped, so one the register + // does not carry is a 404 naming the register, the count of same-slug + // schemas elsewhere, and the relink-schemas repair command — and a slug + // that resolves ELSEWHERE on the instance no longer reshapes this + // register's table. The boundary is deliberately slug-only: a numeric id + // or uuid is unique by construction, so scoping it would protect nothing + // and would refuse callers whose register carries a stale schemas list. + $register = $this->createRegister(1, [1]); $this->registerMapper->method('find')->willReturn($register); - $this->schemaMapper->method('find') - ->willThrowException(new \OCP\AppFramework\Db\DoesNotExistException('Schema not found')); + $this->stubScopedSchema(null); + $this->schemaMapper->method('countBySlug')->willReturn(3); + $this->schemaMapper->expects($this->never())->method('find'); + $this->magicMapper->expects($this->never())->method('syncTableForRegisterSchema'); - $result = $this->controller->sync(1, 999); + $result = $this->controller->sync(1, 'timeEntry'); - $this->assertSame(500, $result->getStatus()); - $data = $result->getData(); - $this->assertSame('Failed to sync magic table', $data['error']); + $this->assertSame(404, $result->getStatus()); + $error = $result->getData()['error']; + $this->assertStringContainsString('is not carried by', $error); + $this->assertStringContainsString('occ openregister:registers:relink-schemas', $error); } public function testSyncReturns500OnException(): void { @@ -145,15 +173,17 @@ public function testSyncReturns500OnException(): void { } public function testSyncWithStringNumericIds(): void { - $register = $this->createRegister(); + // The register must CARRY schema 1: the lookup is register-scoped now, so + // a register with an empty schemas list can no longer resolve anything. + $register = $this->createRegister(1, [1]); $schema = $this->createSchema(); $this->registerMapper->method('find')->willReturn($register); - $this->schemaMapper->method('find')->willReturn($schema); + $this->stubScopedSchema($schema); $this->magicMapper->method('syncTableForRegisterSchema') ->willReturn([]); - // Numeric strings still use find() via is_numeric() check. + // A numeric-string ref resolves through the same scoped lookup. $result = $this->controller->sync('1', '1'); $this->assertSame(200, $result->getStatus()); diff --git a/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php b/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php new file mode 100644 index 0000000000..224323c7c7 --- /dev/null +++ b/tests/Unit/Service/Aggregation/AggregationRegisterScopeTest.php @@ -0,0 +1,431 @@ + + * @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\Service\Aggregation; + +use OCA\OpenRegister\Db\MagicMapper; +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\Aggregation\AggregationCache; +use OCA\OpenRegister\Service\Aggregation\AggregationQuery; +use OCA\OpenRegister\Service\Aggregation\AggregationRunner; +use OCA\OpenRegister\Service\LanguageService; +use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCA\OpenRegister\Service\Object\TranslationHandler; +use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\Search\PlaceholderResolver; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IDBConnection; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * @covers \OCA\OpenRegister\Service\Aggregation\AggregationRunner + * @covers \OCA\OpenRegister\Service\RegisterScopedSchemaResolver + */ +class AggregationRegisterScopeTest extends TestCase { + + private MagicMapper&MockObject $magicMapper; + + private RegisterMapper&MockObject $registerMapper; + + private SchemaMapper&MockObject $schemaMapper; + + private PlaceholderResolver $placeholderResolver; + + private IDBConnection&MockObject $db; + + private AggregationCache&MockObject $cache; + + private PermissionHandler&MockObject $permissionHandler; + + private IUserSession&MockObject $userSession; + + private OrganisationService&MockObject $organisationService; + + private AggregationRunner $runner; + + protected function setUp(): void { + parent::setUp(); + + $this->magicMapper = $this->createMock(MagicMapper::class); + $this->registerMapper = $this->createMock(RegisterMapper::class); + $this->schemaMapper = $this->createMock(SchemaMapper::class); + $this->db = $this->createMock(IDBConnection::class); + $this->cache = $this->createMock(AggregationCache::class); + $this->permissionHandler = $this->createMock(PermissionHandler::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->organisationService = $this->createMock(OrganisationService::class); + + // PlaceholderResolver is declared `final` and cannot be mocked. + $this->placeholderResolver = new PlaceholderResolver($this->userSession); + + $this->userSession->method('getUser')->willReturn(null); + $this->cache->method('get')->willReturn(null); + $this->cache->method('set'); + $this->organisationService->method('getActiveOrganisation')->willReturn(null); + $this->permissionHandler->method('hasPermission')->willReturn(true); + + // Non-Postgres platform forces the PHP fallback path, so the aggregate + // completes without a real database. + $platform = new class { + public function __toString(): string { + return 'OtherPlatform'; + } + }; + $this->db->method('getDatabasePlatform')->willReturn($platform); + + $this->runner = new AggregationRunner( + magicMapper: $this->magicMapper, + registerMapper: $this->registerMapper, + schemaMapper: $this->schemaMapper, + placeholders: $this->placeholderResolver, + db: $this->db, + cache: $this->cache, + permissionHandler: $this->permissionHandler, + userSession: $this->userSession, + organisationService: $this->organisationService, + translationHandler: $this->createMock(TranslationHandler::class), + languageService: $this->createMock(LanguageService::class) + ); + }//end setUp() + + + /** + * Build a persisted-looking schema. + * + * @param int $id The schema id. + * @param string $slug The schema slug. + * @param array $configuration Optional schema configuration (aggregation annotation). + * + * @return Schema The schema. + */ + private function schemaWithId(int $id, string $slug = 'TimeEntry', array $configuration = []): Schema { + $schema = new Schema(); + $schema->setId($id); + $schema->setSlug($slug); + $schema->setTitle('TimeEntry'); + if ($configuration !== []) { + $schema->setConfiguration($configuration); + } + + 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() + + + /** + * Build a stub object row for the PHP fallback path. + * + * @param array $data The object data. + * + * @return ObjectEntity&MockObject The stub row. + */ + private function row(array $data): ObjectEntity&MockObject { + $entity = $this->createMock(ObjectEntity::class); + $entity->method('getObject')->willReturn($data); + + return $entity; + }//end row() + + + /** + * Scoped hit: the ad-hoc surface resolves the schema ref among the named + * register's schemas only. + * + * `SchemaMapper::find()` — the global resolver — must never run. On the live + * instance it is the call that returned planix's schema 161 for hrmq's + * `TimeEntry`. + * + * @return void + */ + public function testAdhocResolvesSchemaWithinTheNamedRegisterOnly(): void { + $this->registerMapper->expects($this->once()) + ->method('find') + ->with('hrmq', $this->anything(), $this->isFalse()) + ->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->magicMapper->method('findAllInRegisterSchemaTable') + ->willReturn([$this->row(['hours' => 3]), $this->row(['hours' => 4])]); + + $result = $this->runner->runAdhocByRef( + registerRef: 'hrmq', + schemaRef: 'TimeEntry', + query: AggregationQuery::create(metric: 'count') + ); + + $this->assertSame(2, $result['value']); + }//end testAdhocResolvesSchemaWithinTheNamedRegisterOnly() + + + /** + * Scoped hit on the named-annotation surface: `run()` scopes too, so the + * `x-openregister-aggregations` annotation and the RBAC `list` gate are read + * off the register's OWN schema and not a same-slug schema from another app. + * + * @return void + */ + public function testNamedAggregationResolvesSchemaWithinTheNamedRegisterOnly(): void { + $schema = $this->schemaWithId( + id: 9466, + configuration: ['x-openregister-aggregations' => ['totalCount' => ['select' => 'count']]] + ); + + $this->registerMapper->method('find')->willReturn($this->registerWith(id: 12, schemaIds: [9466])); + $this->schemaMapper->expects($this->once()) + ->method('findInIds') + ->with('TimeEntry', [9466]) + ->willReturn($schema); + $this->schemaMapper->expects($this->never())->method('find'); + + $this->magicMapper->method('findAllInRegisterSchemaTable')->willReturn([$this->row(['hours' => 3])]); + + $result = $this->runner->run(registerRef: 'hrmq', schemaRef: 'TimeEntry', name: 'totalCount'); + + $this->assertSame(1, $result['value']); + }//end testNamedAggregationResolvesSchemaWithinTheNamedRegisterOnly() + + + /** + * Scoped miss: a slug carried elsewhere on the instance but not by the named + * register is refused with the boundary diagnosis, not resolved globally. + * + * This is the exact live shape — three schemas carry `TimeEntry`, the caller + * named hrmq's register, and hrmq's schema is not among the ids that register + * carries. Serving one of the other two is the defect. + * + * @return void + */ + public function testSlugCarriedElsewhereButNotByTheRegisterIsRefused(): void { + $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'); + + $caught = null; + try { + $this->runner->runAdhocByRef( + registerRef: 'hrmq', + schemaRef: 'TimeEntry', + query: AggregationQuery::create(metric: 'count') + ); + } catch (RuntimeException $e) { + $caught = $e; + } + + $this->assertInstanceOf( + RuntimeException::class, + $caught, + 'A scoped miss MUST refuse; AggregationController maps RuntimeException to HTTP 404' + ); + $message = $caught->getMessage(); + $this->assertStringContainsString('is not carried by register "hrmq" (id 12)', $message); + $this->assertStringContainsString('3 schema(s) elsewhere', $message); + $this->assertStringContainsString('naming a register makes it a boundary', $message); + $this->assertStringContainsString('occ openregister:registers:relink-schemas', $message); + }//end testSlugCarriedElsewhereButNotByTheRegisterIsRefused() + + + /** + * An unknown register is a refusal 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->registerMapper->method('find')->willThrowException(new DoesNotExistException('nope')); + + $this->schemaMapper->expects($this->never())->method('find'); + $this->schemaMapper->expects($this->never())->method('findInIds'); + + $caught = null; + try { + $this->runner->runAdhocByRef( + registerRef: 'no-such-register', + schemaRef: 'TimeEntry', + query: AggregationQuery::create(metric: 'count') + ); + } catch (RuntimeException $e) { + $caught = $e; + } + + $this->assertInstanceOf(RuntimeException::class, $caught); + $message = $caught->getMessage(); + $this->assertStringContainsString("Register not found: 'no-such-register'", $message); + $this->assertStringContainsString('naming a register makes it a boundary', $message); + }//end testUnknownRegisterIsRefusedInsteadOfFallingBackGlobally() + + + /** + * A numeric schema id NOT in the register's list still resolves. + * + * The boundary exists because a SLUG is ambiguous instance-wide; a numeric + * id is unique by construction, so scoping it protects nothing and can + * only refuse a caller whose register carries a stale `schemas` list. + * Enforcing it there turned `POST /api/objects/{registerId}/{schemaId}` + * into a 404 for existing clients — measured in the Newman suite — which + * is what this test now prevents recurring. + * + * @return void + */ + public function testNumericSchemaIdResolvesEvenWhenTheMembershipListIsStale(): void { + $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->once()) + ->method('find') + ->willReturn($this->schemaWithId(id: 161)); + + $result = $this->runner->runAdhocByRef( + registerRef: 'hrmq', + schemaRef: '161', + query: AggregationQuery::create(metric: 'count') + ); + + $this->assertNotNull($result); + }//end testNumericSchemaIdResolvesEvenWhenTheMembershipListIsStale() + + + /** + * CONTROL. The global resolver is no longer reachable when a register is + * named — proven negatively above with `expects($this->never())` — and is + * still the resolver for a caller that names none. + * + * `findSchema()` is the only surface with an optional register: the + * timeseries controller passes one, cross-schema `from:` refs do not. + * + * @return void + */ + public function testFindSchemaWithoutARegisterKeepsGlobalResolution(): void { + $this->schemaMapper->expects($this->once()) + ->method('find') + ->willReturn($this->schemaWithId(id: 161)); + $this->schemaMapper->expects($this->never())->method('findInIds'); + $this->registerMapper->expects($this->never())->method('find'); + + $this->assertSame(161, $this->runner->findSchema(schemaRef: 'TimeEntry')->getId()); + }//end testFindSchemaWithoutARegisterKeepsGlobalResolution() + + + /** + * CONTROL, the other half: the same call WITH a register never reaches the + * global resolver. `timeseries()` validates its field allow-list against the + * schema this returns, so a global resolution here would police one app's + * property list while the aggregate ran over another app's rows. + * + * @return void + */ + public function testFindSchemaWithARegisterNeverReachesGlobalResolution(): void { + $this->registerMapper->method('find')->willReturn($this->registerWith(id: 12, schemaIds: [9466])); + + $this->schemaMapper->expects($this->once()) + ->method('findInIds') + ->with('TimeEntry', [9466]) + ->willReturn($this->schemaWithId(id: 9466)); + $this->schemaMapper->expects($this->never())->method('find'); + + $this->assertSame( + 9466, + $this->runner->findSchema(schemaRef: 'TimeEntry', registerRef: 'hrmq')->getId() + ); + }//end testFindSchemaWithARegisterNeverReachesGlobalResolution() + + + /** + * A register whose `schemas` list was lost cannot resolve anything, and says + * so with the repair command rather than quietly widening to the instance. + * + * This is the shape that hid the original defect: an empty scoped result is + * indistinguishable from "this register holds no objects". + * + * @return void + */ + public function testRegisterWithAnEmptySchemaListIsRefusedWithTheRepairCommand(): void { + $this->registerMapper->method('find')->willReturn($this->registerWith(id: 12, schemaIds: [])); + + $this->schemaMapper->method('findInIds')->willReturn(null); + $this->schemaMapper->method('countBySlug')->willReturn(3); + $this->schemaMapper->expects($this->never())->method('find'); + + $caught = null; + try { + $this->runner->runAdhocByRef( + registerRef: 'hrmq', + schemaRef: 'TimeEntry', + query: AggregationQuery::create(metric: 'count') + ); + } catch (RuntimeException $e) { + $caught = $e; + } + + $this->assertInstanceOf(RuntimeException::class, $caught); + $this->assertStringContainsString('carries no schemas at all', $caught->getMessage()); + $this->assertStringContainsString('occ openregister:registers:relink-schemas', $caught->getMessage()); + }//end testRegisterWithAnEmptySchemaListIsRefusedWithTheRepairCommand() +}//end class diff --git a/tests/Unit/Service/Aggregation/AggregationRunnerTest.php b/tests/Unit/Service/Aggregation/AggregationRunnerTest.php index 75d0489bf4..df7bc2ef4c 100644 --- a/tests/Unit/Service/Aggregation/AggregationRunnerTest.php +++ b/tests/Unit/Service/Aggregation/AggregationRunnerTest.php @@ -167,8 +167,21 @@ public function testLoadSchemaPassesMultitenancyFalseToTheMapper(): void { * * @spec openspec/changes/aggregation-runner-multitenancy-policy/specs/auth-system/spec.md */ - public function testLoadRegisterPassesMultitenancyFalseToTheMapper(): void { - $register = $this->createMock(Register::class); + public function testRegisterLoadPassesMultitenancyFalse(): void { + // The standalone `loadRegister()` is gone — the register is now loaded as + // part of the register-scoped (register, schema) pair resolution, because + // resolving a register WITHOUT then bounding a schema by it is the shape + // that let the aggregation endpoints serve another app's rows. The + // metadata-read bypass it documented is unchanged and is asserted here at + // its new home. + $register = new Register(); + $register->setId(12); + $register->setSlug('zaken'); + $register->setSchemas([7]); + + $schema = new Schema(); + $schema->setId(7); + $schema->setSlug('zaak'); $this->registerMapper->expects($this->once()) ->method('find') @@ -179,12 +192,20 @@ public function testLoadRegisterPassesMultitenancyFalseToTheMapper(): void { ) ->willReturn($register); + $this->schemaMapper->expects($this->once()) + ->method('findInIds') + ->with('zaak', [7]) + ->willReturn($schema); + // The global resolver must not run once a register is named. + $this->schemaMapper->expects($this->never())->method('find'); + $runner = $this->makeRunner(); - $result = $this->privateMethod($runner, 'loadRegister')->invoke($runner, 'zaken'); + $result = $this->privateMethod($runner, 'loadSchemaInRegister')->invoke($runner, 'zaak', 'zaken'); - $this->assertSame($register, $result, 'loadRegister MUST return the register the mapper resolves'); + $this->assertSame($register, $result['register'], 'the register the mapper resolves MUST be returned'); + $this->assertSame($schema, $result['schema'], 'the schema MUST come from the register-scoped lookup'); - }//end testLoadRegisterPassesMultitenancyFalseToTheMapper() + }//end testRegisterLoadPassesMultitenancyFalse() /** * Locks the 404-rethrow path: when the mapper raises DoesNotExistException diff --git a/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php b/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php index f45d52bc9d..99c848a012 100644 --- a/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php +++ b/tests/Unit/Service/Aggregation/CrossSchemaAggregationRunnerTest.php @@ -95,6 +95,34 @@ protected function setUp(): void { // Default: no active organisation. $this->organisationService->method('getActiveOrganisation')->willReturn(null); + // REGISTER-SCOPED RESOLUTION. `run()`/`runAdhocByRef()` no longer resolve + // the schema ref globally and then load the register — they resolve the + // register FIRST and match the schema ref among the ids it carries, via + // SchemaMapper::findInIds(). Rather than restate every test's schema + // fixtures a second time, delegate the scoped lookup to whatever the test + // already stubbed on find() and then apply the boundary the production + // resolver applies: a schema the register does not carry does not resolve. + $this->schemaMapper->method('findInIds')->willReturnCallback( + function (string|int $id, array $schemaIds): ?Schema { + try { + $schema = $this->schemaMapper->find($id, [], true, false); + } catch (\Throwable $e) { + return null; + } + + if (($schema instanceof Schema) === false) { + return null; + } + + $normalised = array_map(static fn ($sid) => (int)$sid, $schemaIds); + if (in_array((int)$schema->getId(), $normalised, true) === false) { + return null; + } + + return $schema; + } + ); + $this->runner = new AggregationRunner( magicMapper: $this->magicMapper, registerMapper: $this->registerMapper, diff --git a/tests/Unit/Service/MigrationServiceTest.php b/tests/Unit/Service/MigrationServiceTest.php index cd629d8959..1025425e4d 100644 --- a/tests/Unit/Service/MigrationServiceTest.php +++ b/tests/Unit/Service/MigrationServiceTest.php @@ -39,10 +39,11 @@ protected function setUp(): void { ); } - private function createRegister(int $id): Register { + private function createRegister(int $id, array $schemaIds = [2]): Register { $register = new Register(); $register->setTitle('TestRegister'); $register->setSlug('test-register'); + $register->setSchemas($schemaIds); $ref = new \ReflectionClass($register); $prop = $ref->getProperty('id'); $prop->setAccessible(true); @@ -66,7 +67,11 @@ public function testResolveRegisterAndSchema(): void { $schema = $this->createSchema(2); $this->registerMapper->method('find')->willReturn($register); - $this->schemaMapper->method('find')->willReturn($schema); + // The schema is resolved WITHIN the register now (findInIds), never by the + // instance-wide find() — a `{register}/{schema}` pair whose schema lives in + // another register used to report on, and migrate, the wrong table. + $this->schemaMapper->method('findInIds')->willReturn($schema); + $this->schemaMapper->expects($this->never())->method('find'); $result = $this->service->resolveRegisterAndSchema(1, 2); @@ -79,7 +84,11 @@ public function testResolveRegisterAndSchemaWithSlugs(): void { $schema = $this->createSchema(2); $this->registerMapper->method('find')->willReturn($register); - $this->schemaMapper->method('find')->willReturn($schema); + // The schema is resolved WITHIN the register now (findInIds), never by the + // instance-wide find() — a `{register}/{schema}` pair whose schema lives in + // another register used to report on, and migrate, the wrong table. + $this->schemaMapper->method('findInIds')->willReturn($schema); + $this->schemaMapper->expects($this->never())->method('find'); $result = $this->service->resolveRegisterAndSchema('test-register', 'test-schema'); diff --git a/tests/Unit/Service/Quality/DuplicateDetectionServiceTest.php b/tests/Unit/Service/Quality/DuplicateDetectionServiceTest.php index ced350c04b..923bb0a0b1 100644 --- a/tests/Unit/Service/Quality/DuplicateDetectionServiceTest.php +++ b/tests/Unit/Service/Quality/DuplicateDetectionServiceTest.php @@ -6,6 +6,8 @@ use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Service\Quality\DuplicateDetectionService; @@ -21,19 +23,44 @@ class DuplicateDetectionServiceTest extends TestCase { /** @var SchemaMapper&MockObject */ private $schemaMapper; + private $registerMapper; + private DuplicateDetectionService $service; protected function setUp(): void { $this->objectService = $this->createMock(ObjectService::class); $this->schemaMapper = $this->createMock(SchemaMapper::class); + $this->registerMapper = $this->createMock(RegisterMapper::class); $this->service = new DuplicateDetectionService( $this->objectService, $this->schemaMapper, + $this->registerMapper, new SimilarityCalculator(), $this->createMock(LoggerInterface::class) ); } + /** + * Stub the register-scoped resolution path the annotation lookup now takes. + * + * The annotation is no longer read via a GLOBAL SchemaMapper::find(): the + * register named in the route is the boundary, so the schema ref is matched + * only among the ids that register carries (SchemaMapper::findInIds()). + * + * @param mixed $schema The schema the scoped lookup should resolve to. + * + * @return void + */ + private function stubScopedSchema($schema): void { + $register = new Register(); + $register->setId(1); + $register->setSlug('reg'); + $register->setSchemas([1]); + + $this->registerMapper->method('find')->willReturn($register); + $this->schemaMapper->method('findInIds')->willReturn($schema); + }//end stubScopedSchema() + /** * Build an ObjectEntity carrying the given uuid + payload. * @@ -108,7 +135,7 @@ public function testRulesFromAnnotationWhenNoneSupplied(): void { 'threshold' => 0.9, ], ]); - $this->schemaMapper->method('find')->willReturn($schema); + $this->stubScopedSchema($schema); // No caller rules — service must fall back to annotation rules. $pairs = $this->service->findDuplicates(1, 1, null); @@ -132,7 +159,7 @@ public function testBlockingKeyPartitionsCandidates(): void { 'threshold' => 0.85, ], ]); - $this->schemaMapper->method('find')->willReturn($schema); + $this->stubScopedSchema($schema); $this->assertSame([], $this->service->findDuplicates(1, 1, null)); } @@ -140,7 +167,7 @@ public function testBlockingKeyPartitionsCandidates(): void { public function testNoRulesAnywhereReturnsEmpty(): void { $schema = $this->createMock(Schema::class); $schema->method('getConfiguration')->willReturn([]); - $this->schemaMapper->method('find')->willReturn($schema); + $this->stubScopedSchema($schema); $this->assertSame([], $this->service->findDuplicates(1, 1, null)); } @@ -187,7 +214,7 @@ public function testNestedDotPathBlockingKeyPartitionsCandidates(): void { 'threshold' => 0.85, ], ]); - $this->schemaMapper->method('find')->willReturn($schema); + $this->stubScopedSchema($schema); $this->assertSame([], $this->service->findDuplicates(1, 1, null)); } @@ -229,7 +256,7 @@ public function testPlainTopLevelFieldBackwardCompatUnaffected(): void { 'threshold' => 0.85, ], ]); - $this->schemaMapper->method('find')->willReturn($schema); + $this->stubScopedSchema($schema); $pairs = $this->service->findDuplicates(1, 1, null); diff --git a/tests/Unit/Service/Quality/QualityStatisticsServiceTest.php b/tests/Unit/Service/Quality/QualityStatisticsServiceTest.php index 300c0abc13..0f0d7f887d 100644 --- a/tests/Unit/Service/Quality/QualityStatisticsServiceTest.php +++ b/tests/Unit/Service/Quality/QualityStatisticsServiceTest.php @@ -29,6 +29,8 @@ use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\SchemaMapper; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Service\Quality\QualityScorer; @@ -49,19 +51,44 @@ class QualityStatisticsServiceTest extends TestCase { */ private $schemaMapper; + private $registerMapper; + private QualityStatisticsService $service; protected function setUp(): void { $this->objectService = $this->createMock(ObjectService::class); $this->schemaMapper = $this->createMock(SchemaMapper::class); + $this->registerMapper = $this->createMock(RegisterMapper::class); $this->service = new QualityStatisticsService( $this->objectService, $this->schemaMapper, + $this->registerMapper, new QualityScorer(), $this->createMock(LoggerInterface::class) ); }//end setUp() + /** + * Stub the register-scoped resolution path the annotation lookup now takes. + * + * The annotation is no longer read via a GLOBAL SchemaMapper::find(): the + * register named in the route is the boundary, so the schema ref is matched + * only among the ids that register carries (SchemaMapper::findInIds()). + * + * @param mixed $schema The schema the scoped lookup should resolve to. + * + * @return void + */ + private function stubScopedSchema($schema): void { + $register = new Register(); + $register->setId(1); + $register->setSlug('reg'); + $register->setSchemas([1]); + + $this->registerMapper->method('find')->willReturn($register); + $this->schemaMapper->method('findInIds')->willReturn($schema); + }//end stubScopedSchema() + /** * Build an ObjectEntity carrying the nil placeholder uuid + payload. * @@ -89,7 +116,7 @@ private function makeObject(string $uuid, array $payload): ObjectEntity { private function stubQualityAnnotation(array $quality): void { $schema = $this->createMock(Schema::class); $schema->method('getConfiguration')->willReturn(['x-openregister-quality' => $quality]); - $this->schemaMapper->method('find')->willReturn($schema); + $this->stubScopedSchema($schema); }//end stubQualityAnnotation() public function testAverageAndBucketsOverScoredSchema(): void { diff --git a/tests/Unit/Service/RegisterScopedSchemaResolverTest.php b/tests/Unit/Service/RegisterScopedSchemaResolverTest.php new file mode 100644 index 0000000000..4a85a63a5f --- /dev/null +++ b/tests/Unit/Service/RegisterScopedSchemaResolverTest.php @@ -0,0 +1,145 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service; + +use OCA\OpenRegister\Db\Register; +use OCA\OpenRegister\Db\RegisterMapper; +use OCA\OpenRegister\Db\Schema; +use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Exception\SchemaNotInRegisterException; +use OCA\OpenRegister\Service\RegisterScopedSchemaResolver; +use PHPUnit\Framework\TestCase; + +/** + * @covers \OCA\OpenRegister\Service\RegisterScopedSchemaResolver + */ +class RegisterScopedSchemaResolverTest extends TestCase { + + private function register(array $schemaIds): Register { + $register = new Register(); + $register->setId(18); + $register->setSlug('hrmq'); + $register->setSchemas($schemaIds); + return $register; + } + + private function schema(int $id, string $slug): Schema { + $schema = new Schema(); + $schema->setId($id); + $schema->setSlug($slug); + return $schema; + } + + /** + * A slug the register carries resolves within it, never instance-wide. + */ + public function testCarriedSlugResolvesWithinTheRegister(): void { + $schemaMapper = $this->createMock(SchemaMapper::class); + $schemaMapper->method('findInIds')->willReturn($this->schema(9466, 'TimeEntry')); + $schemaMapper->expects(self::never())->method('find'); + + $resolver = new RegisterScopedSchemaResolver($this->createMock(RegisterMapper::class), $schemaMapper); + + self::assertSame(9466, $resolver->resolveSchemaWithin($this->register([9466]), 'TimeEntry')->getId()); + } + + /** + * A slug the register does NOT carry is refused even though other + * registers have it — this is the collision the boundary exists for. + */ + public function testUncarriedSlugIsRefusedRatherThanResolvedElsewhere(): void { + $schemaMapper = $this->createMock(SchemaMapper::class); + $schemaMapper->method('findInIds')->willReturn(null); + $schemaMapper->method('countBySlug')->willReturn(3); + $schemaMapper->expects(self::never())->method('find'); + + $resolver = new RegisterScopedSchemaResolver($this->createMock(RegisterMapper::class), $schemaMapper); + + $this->expectException(SchemaNotInRegisterException::class); + $resolver->resolveSchemaWithin($this->register([1, 2]), 'TimeEntry'); + } + + /** + * A NUMERIC id resolves globally when the register's list is stale — the + * regression that 404'd POST /api/objects/{registerId}/{schemaId}. + */ + public function testNumericIdResolvesGloballyWhenTheMembershipListIsStale(): void { + $schemaMapper = $this->createMock(SchemaMapper::class); + $schemaMapper->method('findInIds')->willReturn(null); + $schemaMapper->method('find')->willReturn($this->schema(29, 'persoon')); + + $resolver = new RegisterScopedSchemaResolver($this->createMock(RegisterMapper::class), $schemaMapper); + + self::assertSame(29, $resolver->resolveSchemaWithin($this->register([]), 29)->getId()); + } + + /** + * A numeric id supplied as a STRING behaves identically — the router hands + * path segments over as strings. + */ + public function testNumericStringIdAlsoResolvesGlobally(): void { + $schemaMapper = $this->createMock(SchemaMapper::class); + $schemaMapper->method('findInIds')->willReturn(null); + $schemaMapper->method('find')->willReturn($this->schema(29, 'persoon')); + + $resolver = new RegisterScopedSchemaResolver($this->createMock(RegisterMapper::class), $schemaMapper); + + self::assertSame(29, $resolver->resolveSchemaWithin($this->register([]), '29')->getId()); + } + + /** + * A uuid is unique by construction and resolves globally too. + */ + public function testUuidResolvesGlobally(): void { + $schemaMapper = $this->createMock(SchemaMapper::class); + $schemaMapper->method('findInIds')->willReturn(null); + $schemaMapper->method('find')->willReturn($this->schema(29, 'persoon')); + + $resolver = new RegisterScopedSchemaResolver($this->createMock(RegisterMapper::class), $schemaMapper); + + $uuid = '6dfe55cc-6e73-40e7-88c5-b6e446172a07'; + self::assertSame(29, $resolver->resolveSchemaWithin($this->register([]), $uuid)->getId()); + } + + /** + * A unique identifier that genuinely does not exist still refuses — + * the global fallback is a widening for AMBIGUITY, not for absence. + */ + public function testUnknownNumericIdStillRefuses(): void { + $schemaMapper = $this->createMock(SchemaMapper::class); + $schemaMapper->method('findInIds')->willReturn(null); + $schemaMapper->method('find')->willThrowException(new \OCP\AppFramework\Db\DoesNotExistException('nope')); + $schemaMapper->method('countBySlug')->willReturn(0); + + $resolver = new RegisterScopedSchemaResolver($this->createMock(RegisterMapper::class), $schemaMapper); + + $this->expectException(SchemaNotInRegisterException::class); + $resolver->resolveSchemaWithin($this->register([]), 4242); + } +} From be0231e640e94d311487268f730d72e16c830e56 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 22 Aug 2026 11:05:19 +0200 Subject: [PATCH 048/139] fix(bulk): a partial bulk save is no longer reported as a success (#2780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bulk): a partial bulk save is no longer reported as a success `POST /api/bulk/{register}/{schema}/save` answered `success: true` with the constant `true`, so a batch where objects were rejected reported "completed successfully" and only `saved_count` — which the caller had to think to compare against its own request size — carried the loss. `success` is now true only when every submitted object is accounted for, the status is 422 on a shortfall, and the response carries `failed_count` plus a `failures` list naming each rejected object (index, uuid) with the validation message that refused it — a message the validator already produced and the endpoint discarded. `partial: true` in the request opts into best-effort semantics by relaxing the status to 200; it deliberately does not relax `success`. Closes #2778 * test(bulk): pin the loss report the Newman suite used to tolerate `Bulk Operations Tests / Bulk 1: Save Multiple Objects` asserted `[200, 201, 500]` and never compared saved_count to requested_count, so it passed while the server answered 200 / success:true / saved_count:0 and BOTH objects were lost. Replaying that exact request against a live instance running `development` confirms it: two rows refused with "The required property (name) is missing", nothing written, HTTP 200. The cause of the refusal is a separate defect (#2781): the bulk path's extractBusinessData() strips `name` from the business payload unconditionally, even when the schema declares it. This change does not fix that — it stops the suite from passing while it happens. The assertion now accepts 200/201 (and requires saved_count == requested_count) or 422 (and requires success:false plus a failures[] entry naming each rejected object). 500 is deliberately no longer accepted. Two unit tests replay the real captured server payloads — the refusal and an ordinary all-written batch — so the response contract is pinned against real shapes, not only hand-written mocks. --------- Co-authored-by: Conduction Release Bot --- docs/api/bulk-operations.md | 54 +- lib/Controller/BulkController.php | 155 +++++- lib/Dto/BulkSaveOutcome.php | 279 ++++++++++ lib/Service/Object/BatchOperationStatus.php | 13 +- lib/Service/Object/SaveObject.php | 11 +- lib/Service/Object/SaveObjects.php | 44 +- tests/Unit/Controller/BulkControllerTest.php | 492 ++++++++++++++++++ .../Object/SaveObjectStreamingOutcomeTest.php | 67 +++ .../openregister-crud.postman_collection.json | 32 +- 9 files changed, 1107 insertions(+), 40 deletions(-) create mode 100644 lib/Dto/BulkSaveOutcome.php diff --git a/docs/api/bulk-operations.md b/docs/api/bulk-operations.md index 4b6e14ec8a..aaeb129d43 100644 --- a/docs/api/bulk-operations.md +++ b/docs/api/bulk-operations.md @@ -236,6 +236,13 @@ POST /api/bulk/{register}/{schema}/save Objects should follow the standard OpenRegister object format with `@self` section containing the object data. Objects without an `id` field will be created as new objects, while objects with an existing `id` will be updated. +### Optional Request Flags + +| Flag | Default | Meaning | +| --- | --- | --- | +| `stream` | `false` | Write row-at-a-time through the standard save path instead of the bulk mapper. Slower on flat payloads, faster on heavily cross-referenced ones. | +| `partial` | `false` | Opt into best-effort semantics: a batch with rejected objects answers `200` instead of `422`. It does **not** make `success` true — see below. | + ### Example Request ```bash curl -u 'admin:admin' \ @@ -261,6 +268,10 @@ curl -u 'admin:admin' \ "success": true, "message": "Bulk save operation completed successfully", "saved_count": 1, + "failed_count": 0, + "requested_count": 1, + "failures": [], + "partial": false, "saved_objects": [ { "id": "550e8400-e29b-41d4-a716-446655440002", @@ -269,11 +280,50 @@ curl -u 'admin:admin' \ "created": "2024-01-01T12:00:00Z", "updated": "2024-01-01T12:00:00Z" } - ], - "requested_count": 1 + ] +} +``` + +### Partial Writes + +`success` is `true` **only when every submitted object was written**. If any +object is rejected, the response is `422 Unprocessable Entity` with +`success: false` and a `failures` array naming each rejected object and the +reason it was refused: + +```json +{ + "success": false, + "message": "Bulk save incomplete: 1 of 2 objects were rejected and NOT written. See \"failures\" for the reason per object.", + "saved_count": 1, + "failed_count": 1, + "requested_count": 2, + "partial": false, + "failures": [ + { + "index": 1, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "error": "Schema validation failed: resolutiondate: The data must match the 'date-time' format", + "type": "BulkSafeguardException" + } + ] } ``` +Notes: + +- **`index` is the object's position in the `objects` array you submitted**, and + `uuid` its identifier when it carried one — a rejected *create* has no uuid + yet, which is why the index is there. +- **Objects that did write are not rolled back.** This endpoint has never been + transactional; `saved_count` is what actually persisted and is what you must + reconcile against. +- `failed_count` counts objects; `failures` explains them. If objects go missing + without the save path recording a reason, the gap is reported as a single + `UnaccountedObject` entry rather than being silently dropped. +- Sending `"partial": true` relaxes the status to `200` for callers that + deliberately want best-effort behaviour. `success` stays `false`. + ## Error Handling ### Common Error Responses diff --git a/lib/Controller/BulkController.php b/lib/Controller/BulkController.php index a94654b7f6..4873a153bf 100644 --- a/lib/Controller/BulkController.php +++ b/lib/Controller/BulkController.php @@ -32,6 +32,7 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Dto\BulkSaveOutcome; use OCA\OpenRegister\Exception\RegisterNotFoundException; use OCA\OpenRegister\Exception\SchemaNotFoundException; use OCA\OpenRegister\Service\ObjectService; @@ -326,13 +327,23 @@ public function delete(string $register, string $schema): JSONResponse { * choosing automatically would need a threshold nobody has measured — so it * is a decision the caller makes rather than one inferred here. * + * Both paths report a shortfall the same way — see {@see bulkSaveResponse()}. + * A row this endpoint refused is a row the caller still believes it stored, + * so neither path may answer `success: true` unless every submitted row is + * accounted for. + * * @param array $objects Rows to write. * @param int $register Resolved register id. * @param int|null $schema Resolved schema id, or null for a mixed-schema batch. * @param bool $stream Whether to use the row-at-a-time path. + * @param bool $partial Whether the caller opted into best-effort semantics. * * @return JSONResponse The batch outcome. * + * @SuppressWarnings(PHPMD.StaticAccess) BulkSaveOutcome::from*() are named + * constructors on a value object, not calls into a collaborator — there is + * nothing here a test would want to substitute. + * * @spec openspec/specs/object-lifecycle/spec.md */ private function writeBatch( @@ -340,7 +351,10 @@ private function writeBatch( int $register, ?int $schema, bool $stream, + bool $partial, ): JSONResponse { + $requestedCount = count($objects); + if ($stream === true) { $status = $this->objectService->saveObjectsStreaming( objects: $objects, @@ -348,14 +362,20 @@ private function writeBatch( schema: $schema ); - return new JSONResponse( - data: [ - 'success' => ($status->getFailedCount() === 0), - 'message' => 'Bulk save operation completed (streaming)', - 'saved_count' => ($status->getCreatedCount() + $status->getUpdatedCount()), - 'saved_objects' => $status->toArray(), - 'requested_count' => count($objects), - ] + $savedCount = ($status->getCreatedCount() + $status->getUpdatedCount()); + $accounted = ($savedCount + $status->getUnchangedCount()); + + return $this->bulkSaveResponse( + successMessage: 'Bulk save operation completed (streaming)', + savedCount: $savedCount, + requestedCount: $requestedCount, + outcome: BulkSaveOutcome::fromBatchStatus( + status: $status, + requestedCount: $requestedCount, + accountedCount: $accounted + ), + extra: ['saved_objects' => $status->toArray()], + partial: $partial ); } @@ -369,23 +389,118 @@ private function writeBatch( events: false ); - $savedCount = (($savedObjects['statistics']['saved'] ?? 0) + ($savedObjects['statistics']['updated'] ?? 0)); + $statistics = ($savedObjects['statistics'] ?? []); + $savedCount = ((int)($statistics['saved'] ?? 0) + (int)($statistics['updated'] ?? 0)); + $accounted = ($savedCount + (int)($statistics['unchanged'] ?? 0)); + + return $this->bulkSaveResponse( + successMessage: 'Bulk save operation completed successfully', + savedCount: $savedCount, + requestedCount: $requestedCount, + outcome: BulkSaveOutcome::fromBulkResult( + bulkResult: $savedObjects, + requestedCount: $requestedCount, + accountedCount: $accounted + ), + extra: ['saved_objects' => $savedObjects], + partial: $partial + ); + + }//end writeBatch() + + /** + * Build the bulk-save response so a shortfall can never read as a success. + * + * The rule this enforces (issue #2778): `success` is true only when EVERY + * submitted object was written. It used to be the constant `true` on the + * non-streaming path, so a batch where 27 of 58 rows were refused for + * failing schema validation answered "completed successfully" and only + * `saved_count` — which the caller had to think to compare against its own + * request size — carried the loss. A caller that trusts `success` has + * already moved on by then, and `events: false` on this path means nothing + * downstream notices either. + * + * `success` is therefore unconditional and never softened by `partial`: + * making the flag mean "some rows landed" would re-create exactly the shape + * this fixes. What `partial` changes is only the HTTP status — an opted-in + * caller gets 200 with an honest `success: false` plus the failures, rather + * than a 422 its client library would raise on. + * + * 422 (not 500) matches the sibling `save()` catch for a unique-constraint + * collision: the request was understood, and the server refused the content + * of specific rows. Rows that DID write are not rolled back — this endpoint + * has never been transactional — which is why `saved_count` stays in the + * body of the failure response: it is what the caller must reconcile against. + * + * @param string $successMessage Message used when nothing failed. + * @param int $savedCount Rows created or updated. + * @param int $requestedCount Rows the caller submitted. + * @param BulkSaveOutcome $outcome What the save path lost, and why. + * @param array $extra Path-specific payload merged into the response body. + * @param bool $partial Whether the caller opted into best-effort semantics. + * + * @return JSONResponse The batch outcome. + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + private function bulkSaveResponse( + string $successMessage, + int $savedCount, + int $requestedCount, + BulkSaveOutcome $outcome, + array $extra, + bool $partial, + ): JSONResponse { + $failedCount = $outcome->failedCount; + $complete = $outcome->isComplete(); + + $message = $successMessage; + if ($complete === false) { + $message = sprintf( + 'Bulk save incomplete: %d of %d objects were rejected and NOT written. See "failures" for the reason per object.', + $failedCount, + $requestedCount + ); + } + + $status = Http::STATUS_UNPROCESSABLE_ENTITY; + if ($complete === true || $partial === true) { + $status = Http::STATUS_OK; + } return new JSONResponse( - data: [ - 'success' => true, - 'message' => 'Bulk save operation completed successfully', - 'saved_count' => $savedCount, - 'saved_objects' => $savedObjects, - 'requested_count' => count($objects), - ] + data: array_merge( + [ + 'success' => $complete, + 'message' => $message, + 'saved_count' => $savedCount, + 'failed_count' => $failedCount, + 'requested_count' => $requestedCount, + 'failures' => $outcome->failures, + 'partial' => $partial, + ], + $extra + ), + statusCode: $status ); - }//end writeBatch() + }//end bulkSaveResponse() /** * Perform bulk save operations on objects * + * Request body: + * - `objects` (required) the rows to write. + * - `stream` (optional) row-at-a-time write path — see writeBatch(). + * - `partial` (optional) opt into best-effort semantics: a batch with + * rejected rows still answers 200 instead of 422. It does NOT + * make `success` true — see bulkSaveResponse() for why. + * + * Response body always carries `saved_count`, `failed_count`, + * `requested_count` and `failures` (index / uuid / error / type per + * rejected object), so a shortfall names itself instead of hiding behind a + * count the caller has to think to compare. + * * @param string $register The register identifier * @param string $schema The schema identifier * @@ -454,12 +569,14 @@ public function save(string $register, string $schema): JSONResponse { $schemaToUse = null; } - // See writeBatch() for why `stream` is opt-in. + // See writeBatch() for why `stream` is opt-in, and bulkSaveResponse() + // for what `partial` does and does not change. return $this->writeBatch( objects: $objects, register: $resolved['register'], schema: $schemaToUse, - stream: filter_var(($data['stream'] ?? false), FILTER_VALIDATE_BOOLEAN) + stream: filter_var(($data['stream'] ?? false), FILTER_VALIDATE_BOOLEAN), + partial: filter_var(($data['partial'] ?? false), FILTER_VALIDATE_BOOLEAN) ); } catch (UniqueConstraintViolationException $e) { // WF3 (wave-11): a client-supplied UUID collided with an existing row at a diff --git a/lib/Dto/BulkSaveOutcome.php b/lib/Dto/BulkSaveOutcome.php new file mode 100644 index 0000000000..559ca727b5 --- /dev/null +++ b/lib/Dto/BulkSaveOutcome.php @@ -0,0 +1,279 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Dto; + +use OCA\OpenRegister\Service\Object\BatchOperationStatus; + +/** + * Per-object failure summary for one bulk save call. + * + * @category Dto + * @package OCA\OpenRegister\Dto + */ +class BulkSaveOutcome { + + /** + * How many batch-level (non per-object) error messages the summary repeats. + * + * A batch that fails wholesale can record one error per row; the point of + * the suffix is to name the cause, not to mirror the whole log. + * + * @var integer + */ + private const MAX_REPORTED_BATCH_ERRORS = 5; + + /** + * Number of submitted OBJECTS that were not written. + * + * Deliberately independent of the length of {@see $failures}: rows can + * disappear without a recorded reason, and that gap gets one collective + * entry rather than being rounded away to zero. + * + * @var integer + */ + public readonly int $failedCount; + + /** + * Per-object explanations, each `{index, uuid, error, type}`. + * + * @var array> + */ + public readonly array $failures; + + /** + * Constructor. + * + * @param int $failedCount Number of submitted objects that were not written. + * @param array $failures Per-object explanations. + * + * @return void + */ + public function __construct(int $failedCount, array $failures) { + $this->failedCount = $failedCount; + $this->failures = $failures; + + }//end __construct() + + /** + * Whether every submitted object was accounted for. + * + * @return bool True when nothing was lost. + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + public function isComplete(): bool { + return ($this->failedCount === 0); + + }//end isComplete() + + /** + * Summarise a raw `ObjectService::saveObjects()` result. + * + * The rejected rows arrive in `invalid[]`, each already carrying the + * validator's own message — this is the detail the endpoint used to + * discard, and rediscovering it cost a manual bisect over individual + * fields. + * + * @param array $bulkResult Raw result from ObjectService::saveObjects(). + * @param int $requestedCount Rows the caller submitted. + * @param int $accountedCount Rows the result accounts for (saved + updated + unchanged). + * + * @return self The summarised outcome. + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + public static function fromBulkResult(array $bulkResult, int $requestedCount, int $accountedCount): self { + $failures = []; + $invalid = ($bulkResult['invalid'] ?? []); + if (is_array($invalid) === true) { + foreach ($invalid as $entry) { + if (is_array($entry) === false) { + continue; + } + + $row = ($entry['object'] ?? []); + if (is_array($row) === false) { + $row = []; + } + + $index = null; + if (isset($entry['index']) === true && is_numeric($entry['index']) === true) { + $index = (int)$entry['index']; + } + + $failures[] = [ + 'index' => $index, + 'uuid' => self::extractRowUuid(row: $row), + 'error' => (string)($entry['error'] ?? 'Object was rejected without a recorded reason'), + 'type' => (string)($entry['type'] ?? 'BulkSaveRejection'), + ]; + }//end foreach + }//end if + + $rejected = count($failures); + $unaccounted = max(0, ($requestedCount - $accountedCount - $rejected)); + if ($unaccounted > 0) { + $failures[] = [ + 'index' => null, + 'uuid' => null, + 'error' => sprintf( + '%d object(s) were not written and the save path recorded no per-object reason.%s', + $unaccounted, + self::describeUnattributedErrors(bulkResult: $bulkResult) + ), + 'type' => 'UnaccountedObject', + ]; + } + + return new self(failedCount: ($rejected + $unaccounted), failures: $failures); + + }//end fromBulkResult() + + /** + * Summarise a streaming batch status into the same shape. + * + * The streaming path already derived its `success` from the failed count, + * but reported the detail under its own key layout, so a caller had to know + * which path it had asked for before it could read the answer. + * + * @param BatchOperationStatus $status Per-row outcomes from the streaming save. + * @param int $requestedCount Rows the caller submitted. + * @param int $accountedCount Rows the status accounts for (created + updated + unchanged). + * + * @return self The summarised outcome. + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + public static function fromBatchStatus( + BatchOperationStatus $status, + int $requestedCount, + int $accountedCount + ): self { + $failures = []; + foreach ($status->getFailed() as $failure) { + $failures[] = [ + 'index' => ($failure['index'] ?? null), + 'uuid' => ($failure['uuid'] ?? null), + 'error' => (string)($failure['message'] ?? 'Row failed without a recorded reason'), + 'type' => (string)($failure['exceptionClass'] ?? 'BulkSaveRejection'), + ]; + } + + $unaccounted = max(0, ($requestedCount - $accountedCount - count($failures))); + if ($unaccounted > 0) { + $failures[] = [ + 'index' => null, + 'uuid' => null, + 'error' => sprintf( + '%d object(s) were not written and the streaming path recorded no per-row reason.', + $unaccounted + ), + 'type' => 'UnaccountedObject', + ]; + } + + return new self( + failedCount: ($status->getFailedCount() + $unaccounted), + failures: $failures + ); + + }//end fromBatchStatus() + + /** + * Collect batch-level errors that are not already attributed to a row. + * + * `recordSafeguardRejection()` writes every per-row refusal into BOTH + * `invalid` and `errors`, so echoing `errors` wholesale would repeat what + * the per-object list already says. Only the entries belonging to no row — + * "no objects were successfully prepared", and friends — add information. + * + * @param array $bulkResult Raw result from ObjectService::saveObjects(). + * + * @return string Empty string, or a ' Reported errors: ...' suffix. + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + private static function describeUnattributedErrors(array $bulkResult): string { + $errors = ($bulkResult['errors'] ?? []); + if (is_array($errors) === false) { + return ''; + } + + $messages = []; + foreach ($errors as $error) { + if (is_array($error) === false || ($error['type'] ?? '') === 'BulkSafeguardException') { + continue; + } + + $messages[] = (string)($error['error'] ?? ''); + if (count($messages) >= self::MAX_REPORTED_BATCH_ERRORS) { + break; + } + } + + if ($messages === []) { + return ''; + } + + return ' Reported errors: ' . implode('; ', $messages); + + }//end describeUnattributedErrors() + + /** + * Pull the caller-supplied identifier out of a rejected row. + * + * A rejected row is only actionable if the caller can find it again, and + * the bulk payload spells its identifier several different ways depending + * on which client wrote it. + * + * @param array $row The rejected row as submitted (or as transformed). + * + * @return string|null The row identifier, or null when it carried none. + * + * @spec openspec/specs/object-lifecycle/spec.md + */ + private static function extractRowUuid(array $row): ?string { + $candidate = ( + $row['@self']['id'] + ?? $row['@self']['uuid'] + ?? $row['id'] + ?? $row['uuid'] + ?? null + ); + + if (is_string($candidate) === true || is_int($candidate) === true) { + return (string)$candidate; + } + + return null; + + }//end extractRowUuid() +}//end class diff --git a/lib/Service/Object/BatchOperationStatus.php b/lib/Service/Object/BatchOperationStatus.php index 7cd1084e3a..60fb35eca3 100644 --- a/lib/Service/Object/BatchOperationStatus.php +++ b/lib/Service/Object/BatchOperationStatus.php @@ -88,7 +88,7 @@ class BatchOperationStatus { /** * Failed-row records, each with optional UUID + failure metadata. * - * @var list + * @var list */ private array $failed = []; @@ -180,20 +180,27 @@ public function recordUnchanged(string $uuid): void { /** * Append a failed-row outcome. * + * `$index` is the row's position in the submitted batch. A create that + * fails has no UUID yet, so without the position the caller cannot tell + * which of its rows was refused (issue #2778) — it is optional only so + * callers that genuinely have no position keep working. + * * @param string|null $uuid UUID of the input row, or null when not yet assigned. * @param string $message Human-readable failure message. * @param string $exceptionClass Fully-qualified class name of the exception. + * @param int|null $index Position of the row in the submitted batch, when known. * * @return void * * @spec exclude Boilerplate value-object outcome recorder; the batch outcome aggregator is anchored * to reference-existence-validation at class level. */ - public function recordFailed(?string $uuid, string $message, string $exceptionClass): void { + public function recordFailed(?string $uuid, string $message, string $exceptionClass, ?int $index = null): void { $this->failed[] = [ 'uuid' => $uuid, 'message' => $message, 'exceptionClass' => $exceptionClass, + 'index' => $index, ]; }//end recordFailed() @@ -253,7 +260,7 @@ public function getUnchanged(): array { /** * Get the failed-row records. * - * @return list + * @return list */ public function getFailed(): array { return $this->failed; diff --git a/lib/Service/Object/SaveObject.php b/lib/Service/Object/SaveObject.php index e3155635e1..0332a5670b 100644 --- a/lib/Service/Object/SaveObject.php +++ b/lib/Service/Object/SaveObject.php @@ -4792,7 +4792,14 @@ public function saveObjectsStreaming( $status ??= new BatchOperationStatus(); $status->start(); + // Position in the submitted batch. A failed create has no UUID yet, so + // this is the only thing that lets a caller identify the row it lost + // (issue #2778). Counted rather than read from the key because $rows is + // an iterable and may be a generator. + $rowIndex = -1; + foreach ($rows as $row) { + $rowIndex++; $cacheBefore = count($this->referenceValidationCache); try { @@ -4872,7 +4879,8 @@ public function saveObjectsStreaming( $status->recordFailed( uuid: $rowUuid, message: $e->getMessage(), - exceptionClass: $e::class + exceptionClass: $e::class, + index: $rowIndex ); $this->logger->warning( @@ -4881,6 +4889,7 @@ public function saveObjectsStreaming( 'file' => __FILE__, 'line' => __LINE__, 'uuid' => $rowUuid, + 'index' => $rowIndex, 'error' => $e->getMessage(), ] ); diff --git a/lib/Service/Object/SaveObjects.php b/lib/Service/Object/SaveObjects.php index e58db47aac..d73c59bddc 100644 --- a/lib/Service/Object/SaveObjects.php +++ b/lib/Service/Object/SaveObjects.php @@ -707,7 +707,14 @@ private function applyBulkSafeguards( // defence; phpstan flagged it as dead because the typed shape // contracts on the public method. Callers passing non-array // rows would be a programmer error, not a runtime concern. - unset($index); + // + // The key is the caller's own array position and travels with every + // rejection below (issue #2778) — it used to be discarded here, and + // a refused row then had no way of naming itself in the response. + $rowIndex = null; + if (is_int($index) === true) { + $rowIndex = $index; + } // Step 1: strip dangerous @self fields for non-admins. $sanitised = $this->stripSelfInjectionFields(object: $object, isAdmin: $isAdmin); @@ -733,7 +740,8 @@ private function applyBulkSafeguards( $this->recordSafeguardRejection( object: $sanitised, reason: 'Schema could not be resolved; refusing to write a row whose permissions cannot be checked.', - result: $result + result: $result, + index: $rowIndex ); continue; } @@ -758,7 +766,8 @@ private function applyBulkSafeguards( $this->recordSafeguardRejection( object: $sanitised, reason: 'Schema is appendOnly; UPDATE rejected for row UUID ' . ((string)($uuid ?? '?')), - result: $result + result: $result, + index: $rowIndex ); continue; } @@ -778,7 +787,8 @@ private function applyBulkSafeguards( $this->recordSafeguardRejection( object: $sanitised, reason: 'Permission denied for action ' . $action . ' on schema ' . $rowSchema->getSlug(), - result: $result + result: $result, + index: $rowIndex ); continue; } @@ -798,7 +808,8 @@ private function applyBulkSafeguards( $this->recordSafeguardRejection( object: $sanitised, reason: 'Schema validation failed: ' . $errorMessage, - result: $result + result: $result, + index: $rowIndex ); continue; } @@ -806,7 +817,8 @@ private function applyBulkSafeguards( $this->recordSafeguardRejection( object: $sanitised, reason: 'Schema validation threw: ' . $e->getMessage(), - result: $result + result: $result, + index: $rowIndex ); continue; }//end try @@ -1127,20 +1139,31 @@ private function prefetchExistingRows( /** * Record a rejection from `applyBulkSafeguards` into the result accumulator. * + * The row's position in the submitted batch is recorded alongside the + * reason (issue #2778). Without it a caller reading the response cannot say + * WHICH of its objects was refused: a rejected row often carries no uuid + * yet (it is a create), and the reason alone does not identify it. The + * index is the caller's own array key, so it maps straight back onto the + * payload they sent. + * * @param array $object Sanitised row (post-strip — safe to log shape). * @param string $reason Human-readable rejection reason. * @param array $result Result accumulator (mutated in place). + * @param int|null $index Position of the row in the submitted batch, when known. * * @return void */ - private function recordSafeguardRejection(array $object, string $reason, array &$result): void { + private function recordSafeguardRejection(array $object, string $reason, array &$result, ?int $index = null): void { $result['invalid'][] = [ 'object' => $object, 'error' => $reason, + 'index' => $index, + 'type' => 'BulkSafeguardException', ]; $result['errors'][] = [ 'error' => $reason, 'type' => 'BulkSafeguardException', + 'index' => $index, ]; $result['statistics']['invalid']++; $result['statistics']['errors']++; @@ -1149,7 +1172,12 @@ private function recordSafeguardRejection(array $object, string $reason, array & // kind of thing someone comes to the log to find. $this->logger->info( message: '[SaveObjects] Wave-12 bulk safeguard rejected row', - context: ['file' => __FILE__, 'line' => __LINE__, 'reason' => $reason] + context: [ + 'file' => __FILE__, + 'line' => __LINE__, + 'reason' => $reason, + 'index' => $index, + ] ); }//end recordSafeguardRejection() diff --git a/tests/Unit/Controller/BulkControllerTest.php b/tests/Unit/Controller/BulkControllerTest.php index 26e4bc22aa..068898793b 100644 --- a/tests/Unit/Controller/BulkControllerTest.php +++ b/tests/Unit/Controller/BulkControllerTest.php @@ -9,6 +9,7 @@ use OCA\OpenRegister\Db\RegisterMapper; use OCA\OpenRegister\Db\Schema; use OCA\OpenRegister\Db\SchemaMapper; +use OCA\OpenRegister\Service\Object\BatchOperationStatus; use OCA\OpenRegister\Service\ObjectService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -438,6 +439,10 @@ public function testSaveWithStatisticsMissingUpdatedKey(): void { } public function testSaveWithEmptyStatistics(): void { + // Issue #2778: statistics that account for NOTHING while the caller + // submitted a row is a total loss, not a success. `saved_count` still + // reports 0 (existing behaviour), but the response no longer calls it + // a 200/success — it was previously asserted to be exactly that. $this->stubAdminUser(); $this->stubSchemaLookup(2); $this->setupResolveSuccess(); @@ -451,9 +456,496 @@ public function testSaveWithEmptyStatistics(): void { $result = $this->controller->save('1', '2'); + $this->assertEquals(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus()); + $data = $result->getData(); + $this->assertEquals(0, $data['saved_count']); + $this->assertFalse($data['success']); + $this->assertEquals(1, $data['failed_count']); + $this->assertEquals('UnaccountedObject', $data['failures'][0]['type']); + } + + // ======================================================================== + // save() partial-write reporting — issue #2778 + // ======================================================================== + + /** + * The real defect, in the shape it was found in. + * + * A Jira export row carried `2025-08-15T16:42:42.922+0200` — an RFC-822 + * offset, which fails JSON-Schema `date-time` (RFC3339 wants `+02:00`). + * The validator produced a precise message and the response threw it away, + * answering `success: true` with a smaller `saved_count`. + */ + public function testSaveRejectedObjectFailsTheResponseAndCarriesTheValidationMessage(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + + $validationMessage = 'Schema validation failed: resolutiondate: The data must match the ' + . "'date-time' format (got \"2025-08-15T16:42:42.922+0200\")"; + + $this->objectService->method('saveObjects')->willReturn([ + 'statistics' => ['saved' => 1, 'updated' => 0, 'invalid' => 1, 'errors' => 1], + 'invalid' => [ + [ + 'object' => [ + '@self' => ['id' => 'b2f0d0f1-0000-4000-8000-000000000002'], + 'resolutiondate' => '2025-08-15T16:42:42.922+0200', + ], + 'error' => $validationMessage, + 'index' => 1, + 'type' => 'BulkSafeguardException', + ], + ], + 'errors' => [ + ['error' => $validationMessage, 'type' => 'BulkSafeguardException', 'index' => 1], + ], + ]); + + $this->request->method('getParams')->willReturn([ + 'objects' => [ + ['summary' => 'ok row'], + ['resolutiondate' => '2025-08-15T16:42:42.922+0200'], + ], + ]); + + $result = $this->controller->save('1', '2'); + + // A partial write is not a success, and the status says so too. + $this->assertEquals(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus()); + $data = $result->getData(); + $this->assertFalse($data['success']); + + // The counts still reconcile: 1 written, 1 rejected, 2 requested. + $this->assertEquals(1, $data['saved_count']); + $this->assertEquals(1, $data['failed_count']); + $this->assertEquals(2, $data['requested_count']); + + // The rejected object names itself AND says why. + $this->assertCount(1, $data['failures']); + $failure = $data['failures'][0]; + $this->assertEquals(1, $failure['index']); + $this->assertEquals('b2f0d0f1-0000-4000-8000-000000000002', $failure['uuid']); + $this->assertStringContainsString('date-time', $failure['error']); + $this->assertStringContainsString('2025-08-15T16:42:42.922+0200', $failure['error']); + $this->assertStringContainsString('resolutiondate', $failure['error']); + + // And the message points at the list rather than claiming success. + $this->assertStringContainsString('rejected', $data['message']); + } + + /** + * Must-PASS control: the fix must not turn every batch into a failure. + */ + public function testSaveAllValidBatchStillReportsSuccess(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + $this->objectService->method('saveObjects')->willReturn([ + 'statistics' => ['saved' => 2, 'updated' => 1, 'invalid' => 0, 'errors' => 0], + 'invalid' => [], + 'errors' => [], + ]); + + $this->request->method('getParams')->willReturn([ + 'objects' => [['name' => 'obj1'], ['name' => 'obj2'], ['name' => 'obj3']], + ]); + + $result = $this->controller->save('1', '2'); + + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + $data = $result->getData(); + $this->assertTrue($data['success']); + $this->assertEquals(3, $data['saved_count']); + $this->assertEquals(3, $data['requested_count']); + $this->assertEquals(0, $data['failed_count']); + $this->assertSame([], $data['failures']); + $this->assertEquals('Bulk save operation completed successfully', $data['message']); + } + + /** + * An unchanged row is written work the caller already has — deduplication + * must not be mistaken for loss, or every re-import would report a failure. + */ + public function testSaveUnchangedObjectsDoNotCountAsLoss(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + $this->objectService->method('saveObjects')->willReturn([ + 'statistics' => ['saved' => 1, 'updated' => 0, 'unchanged' => 2], + 'invalid' => [], + 'errors' => [], + ]); + + $this->request->method('getParams')->willReturn([ + 'objects' => [['name' => 'obj1'], ['name' => 'obj2'], ['name' => 'obj3']], + ]); + + $result = $this->controller->save('1', '2'); + + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + $data = $result->getData(); + $this->assertTrue($data['success']); + $this->assertEquals(0, $data['failed_count']); + // saved_count keeps its existing meaning: created + updated only. + $this->assertEquals(1, $data['saved_count']); + } + + /** + * The migration batch, to scale: 31 of 58 stored and NOT ONE recorded + * reason. The shortfall must still be reported rather than rounded away + * because no per-object error happened to be captured. + */ + public function testSaveShortfallWithoutRecordedReasonsIsStillReported(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + $this->objectService->method('saveObjects')->willReturn([ + 'statistics' => ['saved' => 31, 'updated' => 0], + 'invalid' => [], + 'errors' => [], + ]); + + $this->request->method('getParams')->willReturn([ + 'objects' => array_fill(0, 58, ['name' => 'issue']), + ]); + + $result = $this->controller->save('1', '2'); + + $this->assertEquals(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus()); + $data = $result->getData(); + $this->assertFalse($data['success']); + $this->assertEquals(31, $data['saved_count']); + $this->assertEquals(58, $data['requested_count']); + $this->assertEquals(27, $data['failed_count']); + $this->assertCount(1, $data['failures']); + $this->assertEquals('UnaccountedObject', $data['failures'][0]['type']); + $this->assertStringContainsString('27 object(s) were not written', $data['failures'][0]['error']); + } + + /** + * A batch-level error that belongs to no row is quoted into the synthetic + * failure, so the caller does not have to go to the server log for it. + */ + public function testSaveShortfallQuotesBatchLevelErrors(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + $this->objectService->method('saveObjects')->willReturn([ + 'statistics' => ['saved' => 0, 'updated' => 0], + 'invalid' => [], + 'errors' => [ + [ + 'error' => 'No objects were successfully prepared for bulk save', + 'type' => 'NoObjectsPreparedException', + ], + ], + ]); + + $this->request->method('getParams')->willReturn([ + 'objects' => [['name' => 'obj1'], ['name' => 'obj2']], + ]); + + $result = $this->controller->save('1', '2'); + + $data = $result->getData(); + $this->assertFalse($data['success']); + $this->assertEquals(2, $data['failed_count']); + $this->assertStringContainsString( + 'No objects were successfully prepared', + $data['failures'][0]['error'] + ); + } + + /** + * `partial: true` is the opt-in for best-effort semantics: it relaxes the + * HTTP status so a client library does not raise, and it does NOT relax + * `success` — that would rebuild the exact shape #2778 is about. + */ + public function testSavePartialOptInKeepsOkStatusButSuccessStaysFalse(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + $this->objectService->method('saveObjects')->willReturn([ + 'statistics' => ['saved' => 1, 'updated' => 0], + 'invalid' => [ + [ + 'object' => ['uuid' => 'row-2'], + 'error' => 'Schema validation failed: due: format date-time', + 'index' => 1, + 'type' => 'BulkSafeguardException', + ], + ], + 'errors' => [], + ]); + + $this->request->method('getParams')->willReturn([ + 'objects' => [['name' => 'obj1'], ['name' => 'obj2']], + 'partial' => true, + ]); + + $result = $this->controller->save('1', '2'); + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); $data = $result->getData(); + $this->assertFalse($data['success']); + $this->assertTrue($data['partial']); + $this->assertEquals(1, $data['failed_count']); + $this->assertEquals('row-2', $data['failures'][0]['uuid']); + } + + /** + * The streaming path already derived `success` from its failed count, but + * it reported the detail under its own key layout. It now answers with the + * same `failed_count` / `failures` contract as the default path. + */ + public function testSaveStreamingReportsPerRowFailures(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + + $status = new BatchOperationStatus(); + $status->start(); + $status->recordCreated('created-uuid'); + $status->recordFailed( + 'failed-uuid', + "resolutiondate: The data must match the 'date-time' format", + \OCA\OpenRegister\Exception\ValidationException::class, + 1 + ); + $status->complete(); + + $this->objectService->method('saveObjectsStreaming')->willReturn($status); + + $this->request->method('getParams')->willReturn([ + 'objects' => [['name' => 'obj1'], ['name' => 'obj2']], + 'stream' => true, + ]); + + $result = $this->controller->save('1', '2'); + + $this->assertEquals(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus()); + $data = $result->getData(); + $this->assertFalse($data['success']); + $this->assertEquals(1, $data['saved_count']); + $this->assertEquals(1, $data['failed_count']); + $this->assertEquals(2, $data['requested_count']); + $this->assertEquals(1, $data['failures'][0]['index']); + $this->assertEquals('failed-uuid', $data['failures'][0]['uuid']); + $this->assertStringContainsString('date-time', $data['failures'][0]['error']); + } + + /** + * Must-PASS control for the streaming path. + */ + public function testSaveStreamingAllValidStillReportsSuccess(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + + $status = new BatchOperationStatus(); + $status->start(); + $status->recordCreated('uuid-1'); + $status->recordUpdated('uuid-2'); + $status->complete(); + + $this->objectService->method('saveObjectsStreaming')->willReturn($status); + + $this->request->method('getParams')->willReturn([ + 'objects' => [['name' => 'obj1'], ['name' => 'obj2']], + 'stream' => true, + ]); + + $result = $this->controller->save('1', '2'); + + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + $data = $result->getData(); + $this->assertTrue($data['success']); + $this->assertEquals(2, $data['saved_count']); + $this->assertEquals(0, $data['failed_count']); + $this->assertSame([], $data['failures']); + } + + /** + * THE PAYLOAD A REAL SERVER RETURNS. + * + * Captured verbatim from a live OpenRegister instance running `development`, + * by replaying the Newman suite's own request + * (`Bulk Operations Tests / Bulk 1: Save Multiple Objects`): two objects + * submitted against a schema whose `name` property is required, both refused + * because the bulk path strips `name` out of the business payload (#2781). + * + * The instance answered HTTP 200, `success: true`, `saved_count: 0` — and + * both objects were lost. The exact shape #2778 is about, sitting inside the + * repo's own API suite, green. + * + * Two details here are what the hand-written mocks above got wrong, and are + * why this fixture exists: the real `invalid[]` entries come from + * `enforceChunkGuards()`, so they carry NO `index`, and their `object` is the + * TRANSFORMED row — uuid at the top level, business data nested under + * `object`. The failure list must still identify each object from that shape. + */ + public function testRealServerRejectionPayloadIsReportedAsAFailure(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + + $missingName = 'The required property (name) is missing. Please provide a value for ' + . 'this property or set it to null if allowed.'; + + $this->objectService->method('saveObjects')->willReturn([ + 'saved' => [], + 'updated' => [], + 'unchanged' => [], + 'invalid' => [ + [ + 'object' => [ + 'register' => 4894, + 'schema' => 9478, + 'uuid' => '7aeab5c2-f323-4e2f-a5b8-8141a9b264d0', + 'id' => '7aeab5c2-f323-4e2f-a5b8-8141a9b264d0', + 'owner' => 'admin', + 'organisation' => '286a9152-4b09-4714-9115-fabbbad342d0', + 'name' => 'Updated Bulk 1', + 'relations' => [], + 'object' => ['age' => 26], + ], + 'error' => $missingName, + 'type' => 'ValidationException', + ], + [ + 'object' => [ + 'register' => 4894, + 'schema' => 9478, + 'uuid' => 'f2350c89-ecaa-4f4c-adbf-0eba2e49ae3e', + 'id' => 'f2350c89-ecaa-4f4c-adbf-0eba2e49ae3e', + 'owner' => 'admin', + 'organisation' => '286a9152-4b09-4714-9115-fabbbad342d0', + 'name' => 'Updated Bulk 2', + 'relations' => [], + 'object' => ['age' => 31], + ], + 'error' => $missingName, + 'type' => 'ValidationException', + ], + ], + 'errors' => [], + 'statistics' => [ + 'totalProcessed' => 2, + 'saved' => 0, + 'updated' => 0, + 'unchanged' => 0, + 'invalid' => 2, + 'errors' => 2, + 'processingTimeMs' => 0, + ], + ]); + + $this->request->method('getParams')->willReturn([ + 'objects' => [ + ['uuid' => 'u1', 'name' => 'Updated Bulk 1', 'age' => 26], + ['uuid' => 'u2', 'name' => 'Updated Bulk 2', 'age' => 31], + ], + ]); + + $result = $this->controller->save('1', '2'); + + $this->assertEquals(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus()); + $data = $result->getData(); + $this->assertFalse($data['success']); $this->assertEquals(0, $data['saved_count']); + $this->assertEquals(2, $data['failed_count']); + $this->assertEquals(2, $data['requested_count']); + + // Both objects are named — by uuid, since a chunk-guard rejection carries + // no index — and each carries the validator's own words. + $this->assertCount(2, $data['failures']); + $this->assertEquals('7aeab5c2-f323-4e2f-a5b8-8141a9b264d0', $data['failures'][0]['uuid']); + $this->assertEquals('f2350c89-ecaa-4f4c-adbf-0eba2e49ae3e', $data['failures'][1]['uuid']); + $this->assertNull($data['failures'][0]['index']); + $this->assertEquals('ValidationException', $data['failures'][0]['type']); + $this->assertStringContainsString('required property (name) is missing', $data['failures'][0]['error']); + + // No synthetic entry: every lost object is explained by a real rejection. + $this->assertNotContains('UnaccountedObject', array_column($data['failures'], 'type')); + } + + /** + * The same live instance, same endpoint, a batch with NO metadata-name + * collision: two updates addressed by `@self.id` came back `updated: 2` / + * `invalid: 0`. Pinned here as the real-payload must-PASS control — the + * accounting has to call that 200 and `success: true`, or the fix would be + * trading a silent loss for a false alarm on every ordinary bulk write. + */ + public function testRealServerSuccessPayloadStillReportsSuccess(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + + $this->objectService->method('saveObjects')->willReturn([ + 'saved' => [], + 'updated' => [['id' => 'a'], ['id' => 'b']], + 'unchanged' => [], + 'invalid' => [], + 'errors' => [], + 'statistics' => [ + 'totalProcessed' => 2, + 'saved' => 0, + 'updated' => 2, + 'unchanged' => 0, + 'invalid' => 0, + 'errors' => 0, + 'processingTimeMs' => 0, + ], + ]); + + $this->request->method('getParams')->willReturn([ + 'objects' => [ + ['@self' => ['id' => 'a'], 'title' => 'T1 updated', 'age' => 26], + ['@self' => ['id' => 'b'], 'title' => 'T2 updated', 'age' => 31], + ], + ]); + + $result = $this->controller->save('1', '2'); + + $this->assertEquals(Http::STATUS_OK, $result->getStatus()); + $data = $result->getData(); + $this->assertTrue($data['success']); + $this->assertEquals(2, $data['saved_count']); + $this->assertEquals(2, $data['requested_count']); + $this->assertEquals(0, $data['failed_count']); + $this->assertSame([], $data['failures']); + } + + /** + * A streaming batch that simply stops short — rows consumed but never + * classified — is a loss with no recorded reason, and must not read as a + * success either. + */ + public function testSaveStreamingSilentShortfallIsReported(): void { + $this->stubAdminUser(); + $this->stubSchemaLookup(2); + $this->setupResolveSuccess(); + + $status = new BatchOperationStatus(); + $status->start(); + $status->recordCreated('uuid-1'); + $status->complete(); + + $this->objectService->method('saveObjectsStreaming')->willReturn($status); + + $this->request->method('getParams')->willReturn([ + 'objects' => [['name' => 'obj1'], ['name' => 'obj2'], ['name' => 'obj3']], + 'stream' => true, + ]); + + $result = $this->controller->save('1', '2'); + + $this->assertEquals(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus()); + $data = $result->getData(); + $this->assertFalse($data['success']); + $this->assertEquals(2, $data['failed_count']); + $this->assertEquals('UnaccountedObject', $data['failures'][0]['type']); } // publishSchema() tests removed — deprecated per deprecate-published-metadata spec diff --git a/tests/Unit/Service/Object/SaveObjectStreamingOutcomeTest.php b/tests/Unit/Service/Object/SaveObjectStreamingOutcomeTest.php index 4ddca7aeb6..1fb62106eb 100644 --- a/tests/Unit/Service/Object/SaveObjectStreamingOutcomeTest.php +++ b/tests/Unit/Service/Object/SaveObjectStreamingOutcomeTest.php @@ -38,6 +38,7 @@ use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Service\Object\SaveObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use ReflectionProperty; /** @@ -89,6 +90,46 @@ function () use ($handler, $slot, $verdicts, &$call): ObjectEntity { return $handler; }//end handlerYielding() + /** + * A SaveObject whose `saveObject()` throws for exactly one row position. + * + * The failure branch logs, so the logger — a readonly promoted property the + * skipped constructor never filled — is injected here; without it the catch + * block would fail on an uninitialised property instead of on the row. + * + * @param int $failIndex Zero-based position of the row that throws. + * + * @return SaveObject The instrumented handler. + */ + private function handlerYieldingWithFailureAt(int $failIndex): SaveObject { + $handler = $this->createPartialMock(SaveObject::class, ['saveObject']); + + $logger = new ReflectionProperty(SaveObject::class, 'logger'); + $logger->setAccessible(true); + $logger->setValue($handler, $this->createMock(LoggerInterface::class)); + + $call = 0; + $handler->method('saveObject')->willReturnCallback( + function () use ($failIndex, &$call): ObjectEntity { + $current = $call; + $call++; + + if ($current === $failIndex) { + throw new \OCA\OpenRegister\Exception\ValidationException( + 'resolutiondate must match the date-time format' + ); + } + + $entity = new ObjectEntity(); + $entity->setUuid('uuid-' . $call); + + return $entity; + } + ); + + return $handler; + }//end handlerYieldingWithFailureAt() + /** * THE MISSING BRANCH. A row the save path resolved as `unchanged` lands in * the unchanged bucket — the first production caller @@ -173,6 +214,32 @@ public function testEachRowIsClassifiedIndependently(): void { }//end testEachRowIsClassifiedIndependently() + /** + * A FAILED ROW MUST NAME ITSELF. The row that throws is recorded with its + * POSITION in the submitted batch, not only its message: a failed create + * has no uuid yet, so before issue #2778 a caller reading the failure list + * could not tell which of its rows had been refused. + * + * @return void + */ + public function testAFailedRowRecordsItsPositionInTheBatch(): void { + $handler = $this->handlerYieldingWithFailureAt(failIndex: 1); + + $status = $handler->saveObjectsStreaming( + register: 1, + schema: 1, + rows: [['title' => 'a'], ['title' => 'bad'], ['title' => 'c']] + ); + + $failed = $status->getFailed(); + $this->assertCount(1, $failed); + $this->assertSame(1, $failed[0]['index']); + $this->assertSame('resolutiondate must match the date-time format', $failed[0]['message']); + // The rows either side still wrote — failure isolation is unchanged. + $this->assertSame(2, $status->getCreatedCount()); + + }//end testAFailedRowRecordsItsPositionInTheBatch() + /** * THE STALE-SLOT GUARD. Row 1 publishes `unchanged`; row 2 publishes * nothing. Row 2 must fall back to a verdict derived from ITS OWN input diff --git a/tests/integration/openregister-crud.postman_collection.json b/tests/integration/openregister-crud.postman_collection.json index 0040c43eb0..d2be527565 100644 --- a/tests/integration/openregister-crud.postman_collection.json +++ b/tests/integration/openregister-crud.postman_collection.json @@ -4915,20 +4915,38 @@ "listen": "test", "script": { "exec": [ - "// Bulk save with magic tables has a known issue where 'name' is treated as both", - "// a metadata field (_name) and a schema property (name), causing NOT NULL violations.", - "pm.test('Status code is 200 or 201 or 500 (known magic table limitation)', function () {", - " pm.expect([200, 201, 500]).to.include(pm.response.code);", + "// This schema declares a 'name' property, and the bulk path's", + "// extractBusinessData() strips 'name' from the business payload", + "// unconditionally (it is also a magic-table metadata column). The row then", + "// fails the schema's `name` required rule and is refused, so this batch", + "// legitimately writes NOTHING today - see #2781 for that defect.", + "//", + "// What this test pins is that the API SAYS SO. It used to accept", + "// [200, 201, 500] and never compared saved_count to requested_count, so it", + "// passed while the server answered 200 / success:true / saved_count:0 and", + "// BOTH objects were lost (#2778). Whichever way this request goes, a silent", + "// partial write must not be one of them. 500 is deliberately no longer", + "// accepted: a crash on a well-formed batch should fail this suite.", + "pm.test('Status code is 200 or 201 or 422 (rows refused) - never a silent partial', function () {", + " pm.expect([200, 201, 422]).to.include(pm.response.code);", "});", "", + "var jsonData = pm.response.json();", + "", "if (pm.response.code === 200 || pm.response.code === 201) {", - " var jsonData = pm.response.json();", - " pm.test('Bulk save succeeded', function () {", + " pm.test('A 2xx bulk save wrote every object it was given', function () {", " pm.expect(jsonData.saved_objects).to.exist;", + " pm.expect(jsonData.saved_count).to.eql(jsonData.requested_count);", " });", " console.log('\u2713 Bulk save completed, saved:', jsonData.saved_count);", "} else {", - " console.log('\u2139 Bulk save returned 500 (known magic table limitation with name field)');", + " pm.test('A refused batch reports success:false and names every rejected object', function () {", + " pm.expect(jsonData.success).to.eql(false);", + " pm.expect(jsonData.failed_count).to.be.above(0);", + " pm.expect(jsonData.failures).to.be.an('array').that.is.not.empty;", + " pm.expect(jsonData.failures[0].error).to.be.a('string').and.to.have.length.above(0);", + " });", + " console.log('\u2139 Bulk save refused', jsonData.failed_count, 'of', jsonData.requested_count, 'object(s):', jsonData.failures[0].error);", "}" ], "type": "text/javascript" From 5b39b38410d8654c8f2a25f901952c78b2552725 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 22 Aug 2026 12:40:14 +0200 Subject: [PATCH 049/139] fix(objects): a previous caller's pending schema ref no longer hijacks find() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SYMPTOM, ON THE DEV INSTANCE: every read of an `openconnector` object failed with Schema slug "application" is not carried by register "openconnector" (id 65) while the caller had asked for `synchronization`. The schema named in the error was never mentioned by the call that died — it belonged to a previous one. CAUSE. `setSchema()` remembers its RAW ref so that a later `setRegister()` can re-resolve it inside the register the caller names; that is what makes the two setters order-independent, and it is deliberate. But the pending ref is instance state on a SHARED service, and `find()` calls `setRegister()` BEFORE it sets its own schema. So a ref left behind by an unrelated earlier caller — and there are many: TagsController, BulkController, the link controllers and ArchivalRetentionTask all call `setSchema()` directly and never clear it — gets re-resolved inside THIS call's register, which has nothing to do with it. `find()` already snapshots `currentRegister`/`currentSchema` and clears the pending ref in its `finally` (BUG-OBJ-13, #1520), with a comment explaining that leaving a stale ref behind would make the next `setRegister()` re-resolve a restored context against the wrong register. That reasoning was right and the guard was applied at one end only. This is the same rule on the way IN: find() supplies its own scope through its arguments and must never inherit a pending one. The register boundary itself is untouched. A caller that names a register still cannot be served a schema from outside it — that scoping is what stopped `TimeEntry` resolving into the wrong app. What changes is only WHOSE schema ref the boundary is applied to. The test reproduces the instance failure exactly: same resolver, same two frames (ObjectService:505 via :738), same message. It fails on development and passes here. Full tests/Unit/Service suite sits at the development baseline — 7 pre-existing errors either side, one test added. --- lib/Service/ObjectService.php | 21 ++ .../ObjectServiceStaleSchemaRefTest.php | 214 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 tests/Unit/Service/ObjectServiceStaleSchemaRefTest.php diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 2e5793dcf0..20250177ce 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -731,6 +731,27 @@ public function find( $previousRegister = $this->currentRegister; $previousSchema = $this->currentSchema; + // ...AND THE PENDING REF, WHICH IS THE OTHER HALF OF THAT ISOLATION. + // + // `setSchema()` remembers its RAW ref so a later `setRegister()` can + // re-resolve it inside the register the caller names — that is what + // makes the two setters order-independent. But the ref is instance + // state on a SHARED service, and the very next thing this method does + // is call `setRegister()`. A ref left behind by an unrelated earlier + // caller therefore gets re-resolved inside THIS call's register, and + // the call dies on a schema it never mentioned. + // + // Measured on the dev instance: every `openconnector` object read + // failed with `Schema slug "application" is not carried by register + // "openconnector"` while the caller had asked for `synchronization`. + // The name in that error belonged to a previous caller. + // + // The `finally` below already clears this on the way OUT. Clearing it + // on the way IN is the same rule applied at the other end: find() + // supplies its own scope through its arguments and must never inherit + // a pending one. + $this->currentSchemaRef = null; + try { $callRegister = null; $callSchema = null; diff --git a/tests/Unit/Service/ObjectServiceStaleSchemaRefTest.php b/tests/Unit/Service/ObjectServiceStaleSchemaRefTest.php new file mode 100644 index 0000000000..7ef1dddb29 --- /dev/null +++ b/tests/Unit/Service/ObjectServiceStaleSchemaRefTest.php @@ -0,0 +1,214 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://www.OpenRegister.app + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service; + +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\Db\ViewMapper; +use OCA\OpenRegister\Service\DateTimeNormalizer; +use OCA\OpenRegister\Service\FileService; +use OCA\OpenRegister\Service\Object\AuditHandler; +use OCA\OpenRegister\Service\Object\CacheHandler; +use OCA\OpenRegister\Service\Object\CascadingHandler; +use OCA\OpenRegister\Service\Object\DataManipulationHandler; +use OCA\OpenRegister\Service\Object\DeleteObject; +use OCA\OpenRegister\Service\Object\FacetHandler; +use OCA\OpenRegister\Service\Object\GetObject; +use OCA\OpenRegister\Service\Object\LockHandler; +use OCA\OpenRegister\Service\Object\MergeHandler; +use OCA\OpenRegister\Service\Object\MetadataHandler; +use OCA\OpenRegister\Service\Object\MigrationHandler; +use OCA\OpenRegister\Service\Object\PerformanceOptimizationHandler; +use OCA\OpenRegister\Service\Object\PermissionHandler; +use OCA\OpenRegister\Service\Object\QueryHandler; +use OCA\OpenRegister\Service\Object\RelationHandler; +use OCA\OpenRegister\Service\Object\RenderObject; +use OCA\OpenRegister\Service\Object\RevertHandler; +use OCA\OpenRegister\Service\Object\SaveObject; +use OCA\OpenRegister\Service\Object\SaveObjects; +use OCA\OpenRegister\Service\Object\SearchQueryHandler; +use OCA\OpenRegister\Service\Object\UtilityHandler; +use OCA\OpenRegister\Service\Object\ValidateObject; +use OCA\OpenRegister\Service\Object\ValidationHandler; +use OCA\OpenRegister\Service\ObjectService; +use OCA\OpenRegister\Service\ObjectSource\ObjectSourceRegistry; +use OCA\OpenRegister\Service\OrganisationService; +use OCA\OpenRegister\Service\SearchTrailService; +use OCA\OpenRegister\Service\SettingsService; +use OCA\OpenRegister\Exception\SchemaNotInRegisterException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\IAppContainer; +use OCP\IGroupManager; +use OCP\IUserManager; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Regression: a PENDING schema ref left by a previous caller must not be + * re-resolved inside the register a LATER find() names. + */ +class ObjectServiceStaleSchemaRefTest extends TestCase { + + /** @var QueryHandler&MockObject */ + private QueryHandler $queryHandler; + + /** @var RegisterMapper&MockObject */ + private RegisterMapper $registerMapper; + + /** @var SchemaMapper&MockObject */ + private SchemaMapper $schemaMapper; + + private ObjectService $service; + + /** + * Build an ObjectService with every dependency mocked except the mappers. + */ + protected function setUp(): void { + parent::setUp(); + + $this->queryHandler = $this->createMock(QueryHandler::class); + $this->registerMapper = $this->createMock(RegisterMapper::class); + $this->schemaMapper = $this->createMock(SchemaMapper::class); + + $this->service = new ObjectService( + dataManipHandler: $this->createMock(DataManipulationHandler::class), + deleteHandler: $this->createMock(DeleteObject::class), + getHandler: $this->createMock(GetObject::class), + permissionHandler: $this->createMock(PermissionHandler::class), + renderHandler: $this->createMock(RenderObject::class), + saveHandler: $this->createMock(SaveObject::class), + saveObjectsHandler: $this->createMock(SaveObjects::class), + searchQueryHandler: $this->createMock(SearchQueryHandler::class), + validateHandler: $this->createMock(ValidateObject::class), + lockHandler: $this->createMock(LockHandler::class), + auditHandler: $this->createMock(AuditHandler::class), + relationHandler: $this->createMock(RelationHandler::class), + mergeHandler: $this->createMock(MergeHandler::class), + facetHandler: $this->createMock(FacetHandler::class), + metadataHandler: $this->createMock(MetadataHandler::class), + perfOptHandler: $this->createMock(PerformanceOptimizationHandler::class), + queryHandler: $this->queryHandler, + revertHandler: $this->createMock(RevertHandler::class), + utilityHandler: $this->createMock(UtilityHandler::class), + validationHandler: $this->createMock(ValidationHandler::class), + cascadingHandler: $this->createMock(CascadingHandler::class), + migrationHandler: $this->createMock(MigrationHandler::class), + registerMapper: $this->registerMapper, + schemaMapper: $this->schemaMapper, + viewMapper: $this->createMock(ViewMapper::class), + objectMapper: $this->createMock(MagicMapper::class), + fileService: $this->createMock(FileService::class), + userSession: $this->createMock(IUserSession::class), + searchTrailService: $this->createMock(SearchTrailService::class), + groupManager: $this->createMock(IGroupManager::class), + userManager: $this->createMock(IUserManager::class), + organisationService: $this->createMock(OrganisationService::class), + logger: $this->createMock(LoggerInterface::class), + cacheHandler: $this->createMock(CacheHandler::class), + settingsService: $this->createMock(SettingsService::class), + dateTimeNormalizer: $this->createMock(DateTimeNormalizer::class), + container: $this->createMock(IAppContainer::class), + objectSourceRegistry: $this->createMock(ObjectSourceRegistry::class) + ); + + }//end setUp() + + /** + * THE BUG, AS IT PRESENTED ON THE DEV INSTANCE. + * + * `setSchema()` remembers its RAW ref so that a later `setRegister()` can + * re-resolve it inside the register the caller names — that is what makes + * `setSchema()->setRegister()` and `setRegister()->setSchema()` agree. + * + * But the pending ref is instance state on a SHARED service, and `find()` + * calls `setRegister()` BEFORE it sets its own schema. So a ref left behind + * by an unrelated earlier caller gets re-resolved inside THIS call's + * register — a register that has nothing to do with it — and the call dies + * on a schema it never asked for. + * + * Observed: every `openconnector` object read on the instance failed with + * `Schema slug "application" is not carried by register "openconnector"`, + * while the caller had asked for `synchronization`. The name in the error + * belongs to a previous caller. + * + * `find()` already restores `currentRegister`/`currentSchema` and clears the + * pending ref in its `finally` (BUG-OBJ-13). This is the same isolation, + * missing at the OTHER end: entering the call. + * + * @return void + */ + public function testAPendingSchemaRefFromAPreviousCallerIsNotResolvedInThisCallsRegister(): void { + // A previous, unrelated caller anchored the shared service on a schema + // by SLUG and never called find(), so the pending ref is still set. + $appSchema = new Schema(); + $appSchema->setId(28); + $appSchema->setSlug('application'); + + $this->schemaMapper->method('find')->willReturn($appSchema); + $this->service->setSchema('application'); + + // Now an unrelated caller reads an object in a register that does NOT + // carry `application` — the shape of every openconnector read. + $register = new Register(); + $register->setId(65); + $register->setSlug('openconnector'); + $register->setSchemas([221]); + + $synchronization = new Schema(); + $synchronization->setId(221); + $synchronization->setSlug('synchronization'); + + $this->registerMapper->method('find')->willReturn($register); + $this->schemaMapper->method('findBySlugInIds')->willReturnCallback( + static function (string $slug, array $ids) use ($synchronization) { + return ($slug === 'synchronization') ? $synchronization : null; + } + ); + $this->schemaMapper->method('findInIds')->willReturnCallback( + static function ($ref, array $ids) use ($synchronization) { + return ($ref === 'synchronization' || $ref === 221) ? $synchronization : null; + } + ); + + // BEFORE THE FIX this throws SchemaNotInRegisterException naming + // "application" — a schema this call never mentioned. + $this->service->find( + id: 'c3dce9e3-86a8-44d7-98f6-51c4a06f4b31', + register: 'openconnector', + schema: 'synchronization', + _rbac: false, + _multitenancy: false + ); + + $this->addToAssertionCount(1); + + }//end testAPendingSchemaRefFromAPreviousCallerIsNotResolvedInThisCallsRegister() +}//end class From 9cbd7b0282286877ff062c1c39852fe1d9996a1c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 22 Aug 2026 13:06:15 +0200 Subject: [PATCH 050/139] =?UTF-8?q?docs(openspec):=20ADR-098=20programme?= =?UTF-8?q?=20=E2=80=94=20ten=20changes=20for=20human=20workflows=20on=20O?= =?UTF-8?q?R=20Flow=20(#2791)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specs only; no implementation. Each validates under `openspec validate --strict` and each tasks.md is within the 20-checkbox supervisor cap. The chain (depends_on wired so Hydra builds them in order): flow-definition-versioning versioning + run pinning (prerequisite) flow-task-entity the fleet-generic task entity + service flow-user-task-node openregister.user-task + advance budget flow-task-forms the transition `inputs` contract as the form flow-task-inbox-projections NC notifications + CalDAV VTODO + write-back flow-business-timers durable timers, SLA, escalation, opschorting flow-approval-consolidation ApprovalChain/Step retire onto task sequences flow-parallel-streams simultaneous execution of a run's streams flow-cmmn-case-semantics CMMN-shaped case layer over the Petri net notification-scheduled-filter-grammar repairs 24 dead scheduled filters Several instructions given to the spec authors turned out to be wrong and were corrected against source rather than followed: - A human-task node must carry a NON-NULL heartbeat resumeAt. findAbandonedSignals() matches only resume_at IS NULL and FlowRunWorker fails those runs at 14 days, so parking on null would fail exactly the slow municipal approvals this programme exists to support. - Resume must read the task from the node's own resume slot, not context.signal — that is one slot per run, so two user-task nodes in one flow would race over it. - FlowState is keyed per FLOW, not per subject (UNIQUE on flow_id alone), so it cannot anchor per-case state; FlowRun.subject_uuid is the real anchor. - A stricter validator could NOT have blocked the 24 dead notification filters: SchemaMapper discards the validator's errors by design. The gate is the only mechanism that can block, and the change argues it that way. Refs #2787 --- .../.openspec.yaml | 2 + .../flow-approval-consolidation/design.md | 467 ++++++++++++ .../flow-approval-consolidation/proposal.md | 286 ++++++++ .../specs/approval-workflow/spec.md | 344 +++++++++ .../specs/flow-approval-consolidation/spec.md | 503 +++++++++++++ .../flow-approval-consolidation/tasks.md | 196 +++++ .../flow-business-timers/.openspec.yaml | 2 + .../changes/flow-business-timers/design.md | 680 ++++++++++++++++++ .../changes/flow-business-timers/proposal.md | 249 +++++++ .../specs/flow-business-timers/spec.md | 458 ++++++++++++ .../changes/flow-business-timers/tasks.md | 217 ++++++ .../flow-cmmn-case-semantics/.openspec.yaml | 2 + .../flow-cmmn-case-semantics/design.md | 512 +++++++++++++ .../flow-cmmn-case-semantics/proposal.md | 205 ++++++ .../specs/flow-cases/spec.md | 449 ++++++++++++ .../changes/flow-cmmn-case-semantics/tasks.md | 208 ++++++ .../flow-definition-versioning/.openspec.yaml | 2 + .../flow-definition-versioning/design.md | 299 ++++++++ .../flow-definition-versioning/proposal.md | 175 +++++ .../specs/flow-definition-versioning/spec.md | 326 +++++++++ .../flow-definition-versioning/tasks.md | 126 ++++ .../flow-parallel-streams/.openspec.yaml | 2 + .../changes/flow-parallel-streams/design.md | 611 ++++++++++++++++ .../changes/flow-parallel-streams/proposal.md | 185 +++++ .../specs/flow-engine/spec.md | 61 ++ .../specs/flow-parallel-streams/spec.md | 389 ++++++++++ .../changes/flow-parallel-streams/tasks.md | 197 +++++ .../changes/flow-task-entity/.openspec.yaml | 2 + openspec/changes/flow-task-entity/design.md | 448 ++++++++++++ openspec/changes/flow-task-entity/proposal.md | 164 +++++ .../flow-task-entity/specs/flow-tasks/spec.md | 503 +++++++++++++ openspec/changes/flow-task-entity/tasks.md | 165 +++++ .../changes/flow-task-forms/.openspec.yaml | 2 + openspec/changes/flow-task-forms/design.md | 489 +++++++++++++ openspec/changes/flow-task-forms/proposal.md | 263 +++++++ .../specs/flow-task-forms/spec.md | 376 ++++++++++ .../specs/object-lifecycle/spec.md | 103 +++ openspec/changes/flow-task-forms/tasks.md | 162 +++++ .../.openspec.yaml | 2 + .../flow-task-inbox-projections/design.md | 622 ++++++++++++++++ .../flow-task-inbox-projections/proposal.md | 252 +++++++ .../specs/flow-task-projections/spec.md | 510 +++++++++++++ .../specs/object-interactions/spec.md | 288 ++++++++ .../flow-task-inbox-projections/tasks.md | 206 ++++++ .../flow-user-task-node/.openspec.yaml | 2 + .../changes/flow-user-task-node/design.md | 391 ++++++++++ .../changes/flow-user-task-node/proposal.md | 206 ++++++ .../specs/flow-user-task-node/spec.md | 402 +++++++++++ openspec/changes/flow-user-task-node/tasks.md | 148 ++++ .../.openspec.yaml | 2 + .../design.md | 482 +++++++++++++ .../proposal.md | 183 +++++ .../specs/notificatie-engine/spec.md | 320 +++++++++ .../tasks.md | 121 ++++ 54 files changed, 13967 insertions(+) create mode 100644 openspec/changes/flow-approval-consolidation/.openspec.yaml create mode 100644 openspec/changes/flow-approval-consolidation/design.md create mode 100644 openspec/changes/flow-approval-consolidation/proposal.md create mode 100644 openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md create mode 100644 openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md create mode 100644 openspec/changes/flow-approval-consolidation/tasks.md create mode 100644 openspec/changes/flow-business-timers/.openspec.yaml create mode 100644 openspec/changes/flow-business-timers/design.md create mode 100644 openspec/changes/flow-business-timers/proposal.md create mode 100644 openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md create mode 100644 openspec/changes/flow-business-timers/tasks.md create mode 100644 openspec/changes/flow-cmmn-case-semantics/.openspec.yaml create mode 100644 openspec/changes/flow-cmmn-case-semantics/design.md create mode 100644 openspec/changes/flow-cmmn-case-semantics/proposal.md create mode 100644 openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md create mode 100644 openspec/changes/flow-cmmn-case-semantics/tasks.md create mode 100644 openspec/changes/flow-definition-versioning/.openspec.yaml create mode 100644 openspec/changes/flow-definition-versioning/design.md create mode 100644 openspec/changes/flow-definition-versioning/proposal.md create mode 100644 openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md create mode 100644 openspec/changes/flow-definition-versioning/tasks.md create mode 100644 openspec/changes/flow-parallel-streams/.openspec.yaml create mode 100644 openspec/changes/flow-parallel-streams/design.md create mode 100644 openspec/changes/flow-parallel-streams/proposal.md create mode 100644 openspec/changes/flow-parallel-streams/specs/flow-engine/spec.md create mode 100644 openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md create mode 100644 openspec/changes/flow-parallel-streams/tasks.md create mode 100644 openspec/changes/flow-task-entity/.openspec.yaml create mode 100644 openspec/changes/flow-task-entity/design.md create mode 100644 openspec/changes/flow-task-entity/proposal.md create mode 100644 openspec/changes/flow-task-entity/specs/flow-tasks/spec.md create mode 100644 openspec/changes/flow-task-entity/tasks.md create mode 100644 openspec/changes/flow-task-forms/.openspec.yaml create mode 100644 openspec/changes/flow-task-forms/design.md create mode 100644 openspec/changes/flow-task-forms/proposal.md create mode 100644 openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md create mode 100644 openspec/changes/flow-task-forms/specs/object-lifecycle/spec.md create mode 100644 openspec/changes/flow-task-forms/tasks.md create mode 100644 openspec/changes/flow-task-inbox-projections/.openspec.yaml create mode 100644 openspec/changes/flow-task-inbox-projections/design.md create mode 100644 openspec/changes/flow-task-inbox-projections/proposal.md create mode 100644 openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md create mode 100644 openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md create mode 100644 openspec/changes/flow-task-inbox-projections/tasks.md create mode 100644 openspec/changes/flow-user-task-node/.openspec.yaml create mode 100644 openspec/changes/flow-user-task-node/design.md create mode 100644 openspec/changes/flow-user-task-node/proposal.md create mode 100644 openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md create mode 100644 openspec/changes/flow-user-task-node/tasks.md create mode 100644 openspec/changes/notification-scheduled-filter-grammar/.openspec.yaml create mode 100644 openspec/changes/notification-scheduled-filter-grammar/design.md create mode 100644 openspec/changes/notification-scheduled-filter-grammar/proposal.md create mode 100644 openspec/changes/notification-scheduled-filter-grammar/specs/notificatie-engine/spec.md create mode 100644 openspec/changes/notification-scheduled-filter-grammar/tasks.md diff --git a/openspec/changes/flow-approval-consolidation/.openspec.yaml b/openspec/changes/flow-approval-consolidation/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/flow-approval-consolidation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/flow-approval-consolidation/design.md b/openspec/changes/flow-approval-consolidation/design.md new file mode 100644 index 0000000000..00a876c0e6 --- /dev/null +++ b/openspec/changes/flow-approval-consolidation/design.md @@ -0,0 +1,467 @@ +# Design: flow-approval-consolidation + +## Context + +See proposal.md — Why. The design-relevant state of the code today: + +- `openregister_approval_chains` and `openregister_approval_steps` are created + by `lib/Migration/Version1Date20260325000003.php:53-220`; `requester_id` was + added later by `lib/Migration/Version1Date20260714010000.php:70-89`. The + steps table indexes `(chain_id, object_uuid)`, `status` and `role` + (`:218-220`) — the exact three access paths the task inbox already serves. +- `ApprovalService::initializeChain()` (`lib/Service/ApprovalService.php:103-138`) + creates one row per step definition, `pending` for index 0 and `waiting` for + the rest, and dispatches the initiated event only for index 0 (`:129-136`). +- `approveStep()` (`:158-238`) checks separation of duties (`:169`), then + `isInGroup()` (`:171` → `:412`), then writes the decision onto the step row + itself (`:174-178`), then promotes the lowest-ordered `waiting` step to + `pending` **in the same request** (`:193-204`), then dispatches Approved + followed by either Initiated or Completed (`:209-236`). +- `rejectStep()` (`:261-320`) terminates the chain — there is no next-step + event and no propagation onto the remaining `waiting` rows, which simply + stay `waiting` forever until the gate deletes them. +- `ApprovalChainGateListener::evaluateGate()` + (`lib/Listener/ApprovalChainGateListener.php:175-262`) is the enforcement + point. It calls `installSchema()` first for idempotency (`:186`), fails + closed when the chain cannot be resolved (`:200-206`), releases when every + step is approved (`:225`), **deletes** the step rows of a rejected cycle + (`:232`), and otherwise provisions and rejects (`:249-259`). +- `ApprovalChainAnnotationInstaller::upsertChain()` (`:134-175`) compiles + `approvers[].role` into a JSON `steps` array of `{order, role}`. +- `ApprovalChainAdvanceListener` (`lib/Listener/ApprovalChainAdvanceListener.php:69-124`) + subscribes to the completed event and calls + `TransitionEngine::transition()` fail-soft (`:110-121`). +- The only fleet subscriber to any of the four events is filinq/docudesk, + registering all four onto one listener + (`../docudesk/lib/AppInfo/SigningEventRegistrar.php:64-67`). +- `AwaitSignalNode::configKeys()` (`lib/Service/Flow/Nodes/AwaitSignalNode.php:180`) + is `['question','assignee','signalKey','heartbeatMinutes','failOnReject']` — + no correlation field. `FlowRunController::resume()` + (`lib/Controller/FlowRunController.php:425-450`) resolves the run by uuid + (`:427`) and authorizes only "may run this flow" (`:432`). +- openconnector's sweep (`../openconnector/lib/Service/ApprovalService.php:638-681`) + pages 500 `pending` rows and filters `expiresAt` in PHP (`:655-658`), and + collapses `onTimeout: error` and `skip` onto the same write (`:662`). + `flow-business-timers` already specified the corrected timer half. + +## Goals / Non-Goals + +**Goals:** + +- One decision surface for approval work, with the retired one incapable of + being reached rather than merely deprecated. +- Ordering as an explicit, authorized, audited construct — the twenty lines at + `ApprovalService.php:193-204` made into a contract instead of a side effect + of a loop. +- A migration whose failure mode is a loud stop, not a half-migrated database + reporting success. +- Zero edits to any schema declaring `x-openregister-approval-chains`, in any + app, on upgrade. +- A retirement contract precise enough that a leaf app's migration is a + mechanical exercise and a gate can check it. + +**Non-Goals:** + +- **Extending the declarative dialect.** Per-step deadlines, parallel + positions, quorum, escalation policy and delegation policy on + `x-openregister-approval-chains` are all desirable and all deliberately + absent. A retirement that also grows the contract cannot be verified as + behaviour-preserving. +- **Migrating any leaf app.** Not one line changes in procest, decidiq, + pipelinq, planix, buildiq, filinq or openconnector. +- **Dropping the legacy tables.** Left for a follow-up migration for the + reason argued in Decision 6. +- **A general workflow-instance store.** A sequence is ordinal. Stages, + sentries and milestones are `flow-cmmn-case-semantics`. +- **Fixing openconnector's unbounded sweep or its collapsed `onTimeout`.** + Both are recorded; the corrected semantics live in `flow-business-timers` + and the defects are issues, not edits here. + +## Decisions + +### Decision 1: an approval is a task SEQUENCE, not a generated flow + +The tempting move, given ADR-098's "one engine", is to compile a declared +chain into a flow definition of N `openregister.user-task` nodes in series and +queue a run per gated object write. It was considered and rejected on three +measured grounds. + +**The gate is synchronous and the engine is not.** `ApprovalChainGateListener` +runs inside `ObjectUpdatingEvent` and must decide *now* whether the write is +refused. A flow run is queued and advanced by a worker; the stock cadence is +five minutes (`AwaitSignalNode.php:82-83`). The gate would have to provision a +run and then immediately ask it a question the run has not reached yet. + +**It would make step-to-step advance asynchronous, which is a regression.** +Today the next approver is pending before `approveStep()` returns +(`ApprovalService.php:193-204`). Through the engine that promotion is a +transition, so it costs either an `advance` budget on every generated node or +a worker pass. ADR-098 D9 gives us the budget, but spending it to reproduce +behaviour that is currently free is a worse trade than not needing it. + +**It would put machine-authored definitions into the version lineage.** +`flow-definition-versioning` guarantees one published version per flow and a +pin per run. A definition generated from a schema annotation would need +publishing on every schema save, would deprecate its predecessor each time, +and would accumulate a version row per schema edit for a graph nobody +authored. The change that made publication a human-rate event would be undone +by a listener. + +So the sequence is its own small construct: `openregister_task_sequences` +holding template, anchor, requester, resolved tier, position cursor, outcome +and timestamps, with the positions being ordinary tasks carrying a +`sequence_uuid` and an ordinal. + +The escape hatch is the point of the whole chain: an author who wants a graph +— branches, timers, parallel approvers, sub-flows — uses `user-task` nodes in +a real flow. The sequence exists for the case that has no graph, which is +every schema-declared approval in the fleet today. + +### Decision 2: the annotation is the contract; only what executes it moves + +`x-openregister-approval-chains` is not an implementation detail of the +retired engine. It is the surface fleet schemas are authored against, and it +is load-bearing for SAFETY: the gate refuses a transition until the chain +completes, and refuses fail-closed when it cannot provision +(`ApprovalChainGateListener.php:200-206`). Retiring the annotation with the +tables would turn every declared gate into an open door on upgrade — a +fail-open regression delivered by a refactor, which is the worst shape a +security change can take. + +So the annotation keeps its exact declared shape and its place in the schema +annotation vocabulary. `ApprovalChainAnnotationInstaller` becomes a compiler +to a task template; `ApprovalChainGateListener` keeps its two error codes +(`approval-chain-pending`, `approval-chain-misconfigured`) because leaf UIs +match on the strings; `ApprovalChainAdvanceListener` swaps its subscription. +The three classes keep their names: renaming them would produce a diff in +which nothing is recognisable and the behaviour-preservation argument becomes +unreviewable. + +### Decision 3: the chain is a TEMPLATE, the object's cycle is a SEQUENCE + +`ApprovalChain` conflates two things: the configuration (name, schema, +statusField, `steps` JSON) and the identity a step points at via `chainId`. +`flow-task-entity` already has the configuration half — `template_id`, +`template_version` and a frozen `template_snapshot`, with the freeze specified +so a running instance cannot be re-shaped by an edit. + +So a chain becomes a task template and an object's attempt becomes a sequence +instantiated from it, with the template snapshot frozen at provisioning. This +also fixes a live hazard for free: today `upsertChain()` rewrites the chain's +`steps` JSON on every schema save (`ApprovalChainAnnotationInstaller.php:164`) +while in-flight steps are still pointing at that chain by id, so an +administrator editing a schema silently changes the definition of an approval +that is already half-decided. The frozen snapshot makes that impossible +without anybody having to notice it was possible. + +The resolved amount tier is frozen the same way and for the same reason: today +`resolveStepsOverride()` (`ApprovalChainGateListener.php:277-300`) re-resolves +the tier from `$newData` on every attempt, so raising the amount mid-cycle +re-routes an approval that is already running. + +### Decision 4: full cutover, because the one consumer is bidirectional + +A shim was costed. It buys nothing. + +The four events have exactly one registered fleet subscriber +(`SigningEventRegistrar.php:64-67`), and that subscriber's contract is a loop, +not an observation: the docblock at +`../docudesk/lib/EventListener/ApprovalStepListener.php:20-23` states that the +signing provider *"is then responsible for calling +`ApprovalService::approveStep` / `rejectStep` back, closing the loop."* +Re-emitting the four events from the task service while deleting the service +they reply to would leave filinq receiving prompts it cannot answer — a green +integration that silently never completes a signature. That is a worse failure +than a load-time break, because nobody looks at it. + +So: events removed, service removed, routes removed, components removed. An +app registering a listener for a deleted class fails at load, visibly, on the +day of deploy. filinq migrates in `filinq: migrate-signing-to-or-tasks`. + +The one thing that is NOT removed on the same principle is the pair of error +code strings. They are matched by leaf UIs and cost nothing to keep; breaking +them would be gratuitous. + +### Decision 5: the rejected cycle is closed, not deleted + +`ApprovalChainGateListener.php:232` calls +`deleteByChainAndObject()` when a rejected cycle is found, so a resubmission +destroys the record of who refused and why. The comment calls it "clear it so +a fresh attempt opens a new cycle" — the intent is right and the mechanism is +a data loss. A sequence is terminal-and-kept; a new attempt opens a new +sequence with a later open time. Resubmission behaviour is byte-identical from +the author's side; the audit stops disappearing. + +This is the one place where the migrated behaviour differs observably from the +retired behaviour on a non-error path, and it is called out in the spec +because a reviewer should not have to discover it. + +### Decision 6: the migration is one-way, and rollback is bounded and explicit + +The legacy tables are **not dropped**. They are left populated, and each +migrated step records the task it became while each migrated task records the +step it came from, so the two sets reconcile by identity rather than by +counting. + +That gives a clean rollback for the only window where rollback is actually +safe: **before the first post-cutover decision**. Redeploying the previous app +version restores the retired engine over rows it never stopped owning, and +nothing was lost, because nothing had been decided on the new surface yet. + +After the first post-cutover decision, rollback is NOT free, and the design +refuses to pretend otherwise. A decision recorded on a task has no place in +the old schema that can hold its performer type, its on-behalf-of identity or +its append-only audit. The supported path is a reverse repair step shipped +with this change that writes migrated tasks' decisions back onto their +originating step rows — outcome, decider, comment, decision time — for the +subset the old schema can express, and REPORTS what it could not carry rather +than dropping it. Beyond that, the answer is roll forward. + +Dropping the tables is a separate, later migration, deliberately not part of +the rollback path: dropping a table that a re-deploy would need back is how a +rollback becomes an outage. + +### Decision 7: correlation resolution is an indexed column, never a JSON scan + +The correlation key is stored in its own indexed column on the run, populated +when the await-signal step suspends. The alternative — resolving by scanning +`context` JSON for suspended runs — is what the existing signal path already +effectively costs, and it would put a table scan on every inbound webhook. + +Resolution is fail-closed in both directions and this is the whole design: +zero matches is a 404 and is NOT buffered (a buffered signal is a signal +delivered to a run that suspends later and was never the addressee); more than +one match is a 409 and wakes nothing (picking one is picking wrong half the +time, silently). Uniqueness is not enforced by a database constraint, because +two runs legitimately holding the same key is a modelling mistake by the +author, not a corruption — it must be reported at delivery, where the author +sees it, rather than at suspension, where it would fail a run that is behaving +as written. + +Authorization is unchanged from `resume()`: same authority, different address. +The known hole at `FlowRunController.php:423-436` — "may run the flow" is not +"may decide this" — is not widened, because a correlated signal explicitly +cannot complete a task. That is the boundary `flow-user-task-node` drew and +this change restates rather than moves. + +### Decision 8: separation of duties moves up, and gets stricter exactly once + +`verifySeparationOfDuties()` (`ApprovalService.php:334-352`) resolves the +schema lazily and defaults to ON when a declarative entry exists +(`resolveSeparationOfDuties()`, `:371-397`, `($entry['separationOfDuties'] ?? +true) !== false`). That default is kept — an unstated policy on an approval is +the safe one. + +The one deliberate tightening: the check runs against the acting identity AND +the on-behalf-of identity. Delegation does not exist in the retired engine, so +there is no behaviour to preserve here; there is only a hole to not dig. A +delegated self-approval is a self-approval. + +The refusal stays distinguishable from an authorization failure, which the +current implementation already gets right and documents at +`ApprovalService.php:165-168`: separation of duties is evaluated BEFORE the +group check so a self-decision gets an honest error instead of being masked. + +### Decision 9: the anti-pattern gate lives in hydra-gates, not in OpenRegister + +The rules are fleet policy (ADR-022), the checked artefacts are other repos, +and the runner is already the single source of truth for mechanical checks. +OpenRegister ships the CONTRACT — the spec requirements and the retirement +inventory the gate reads — and the gate itself is a hydra-side deliverable in +`ConductionNL/.github`, `hydra-gates/`. + +The gate must check three different kinds of thing and the third is the one +that makes it useful now rather than in six months: shapes (a home-grown step +engine, a stored `overdue`, a definition mirror), declarations (a schema whose +properties mirror `nodes`/`edges`/`limits`/`trigger`), and **broken +integrations** (a call to a removed route, a listener registration for a +removed event class). The third category is not a style finding; it is a +runtime failure detected statically, and it is what turns +`consume-or-approval-workflow-fleet-wide`'s proposed 90-day WARN period into a +sensible policy rather than a delay. + +### Decision 10: hermiq's mirror is contract-retired here and deleted there + +The rule — contribute node types, resolve definitions from the flow entity — +is fleet-wide and belongs in this spec. Removing `agentflow` and +`agentflowrun` from `../hermiq/lib/Settings/hermiq_register.json:3589,3678` is +a hermiq migration with hermiq's own data in it, and it is named as +`hermiq: retire-agentflow-object-store`. + +Worth recording why the mirror is still dangerous although the UI already +moved: `../hermiq/src/manifest.json:1248` re-pointed the index at +`/api/flows?app=hermiq`, and `SeedHydraTriageFlow.php:331` writes through +`FlowMapper` — but `SeedHydraTriageFlow.php:114` still declares +`FLOW_SCHEMA = 'agentflow'` and the two schemas are still installed. A +declared mirror with a live constant pointing at it is one convenient patch +away from being written to again, which is exactly how it drifted the first +time. + +## Declarative-vs-imperative decision (ADR-031) + +ADR-031's default is declarative: behaviour belongs in `x-openregister-*` on a +schema, executed by the platform, not in a new Service class. This change is +unusual in the chain because it is the one that most obviously keeps the +declarative path — and it keeps it deliberately, not by accident. + +**What stays declarative, and gets stronger.** +`x-openregister-approval-chains` remains the authoring surface for every +schema-gated approval in the fleet, unchanged in shape. Nothing is moved from +the annotation into code; the annotation gains reach, because what it +provisions now has deadlines, escalation, delegation, an inbox and an audit it +never had. A schema author writes the same eight lines and gets strictly more. + +**What is imperative, and why the dialect cannot hold it.** The sequence +service — provision, enable-next, terminate-remainder, freeze the snapshot and +the tier — is imperative for the same structural reason +`flow-definition-versioning` gave for its own guard: the declarative lifecycle +dialect operates on register OBJECTS, executed by +`lib/Service/Lifecycle/TransitionEngine.php`. A task is not a register object +— `flow-task-entity` chose a native table over OR objects and recorded why — +so `x-openregister-lifecycle` has no schema to hang on and the transition +`inputs` contract (`TransitionEngine.php:675-704`) has no register write to +validate against. Expressing sequence advance declaratively would mean putting +tasks back into a register, undoing the decision the entity change made. + +The ordering rule is also cross-row, which the dialect does not express in any +form: "enabling position N+1 is permitted iff position N is terminal with an +approving outcome and no earlier position is rejected" is a statement about +sibling rows, and the dialect's guards are per-object. + +**Notifications stay in the ADR-031 subsystem.** This change adds no channel, +no template and no dispatch of its own. A position becoming enabled is a +transition on a system entity, and +`AnnotationNotificationDispatcher::dispatchWithSchema()` is already public and +already serves six non-object system entities — the same seam +`flow-task-inbox-projections` rides. `transition(action)` trigger matching +already exists in the dispatcher. Nothing here needs a second notification +path, and building one would be the anti-pattern this change exists to +enforce against. + +**No seed data (ADR-001).** No register and no schema is introduced or +modified: the sequence is a native table, the templates are created from +schemas that already exist, and the annotation vocabulary entry is already +registered. The equivalent obligation is the data migration and its +verification below. + +## Risks / Trade-offs + +- **filinq breaks at deploy, by design.** → Accepted and sequenced: the + breakage is at app load (a listener registration for a missing class), not + at signature time, so it is discovered by the deploy rather than by a + customer waiting for a document to be signed. The migration change is named + and must land in the same release train. Deploying this change without it + leaves filinq's signing flow down. +- **A partially applied migration is the worst possible state**: some + approvals decidable on tasks, some still on steps. → Mitigated by the + verification being part of the migration and failing loudly, by the legacy + decision paths being gone in the same release so a half-migrated step cannot + be decided at all, and by the reconciliation being by identity rather than + by count. +- **The rollback window is narrow and depends on nobody deciding anything.** + → Stated rather than hidden (Decision 6), with a reverse repair for the + expressible subset and an explicit "roll forward" beyond it. An operator who + believes rollback is free after a week of decisions is the actual risk, so + the migration writes the cutover timestamp where an operator will find it. +- **The sequence is a second orchestration construct beside the flow.** → + Bounded on purpose: ordinal, no branching, no parallelism, no timers of its + own. If it ever needs a branch, that is the signal that the case belongs in + a flow, and the escape hatch already exists. +- **Freezing the template snapshot changes behaviour for an administrator who + relied on editing a schema to re-shape a running approval.** → That + behaviour is a bug being removed, not a feature; but it is observable, so + it is specified rather than slipped in. +- **The correlation key is author-supplied, so two runs can collide.** → + Reported at delivery as an ambiguity refusal rather than guessed. A + uniqueness constraint was rejected because it would fail the second run at + suspension time for a mistake that only matters if a signal ever arrives. +- **`consumedAt` has no confirmed home** (see Open Questions). → Provisionally + a `consume` verb on the task audit; the risk if that is wrong is confined to + openconnector's migration, which is a separate change, and the spec requires + a home to exist before the runner retires — so getting it wrong blocks a + retirement rather than losing a semantic. + +## Migration Plan + +1. **Schema.** Create `openregister_task_sequences` (`uuid`, `template_id`, + `template_version`, `template_snapshot`, `anchor_object_uuid`, + `register_id`, `schema_id`, `chain_key`, `requester_id`, `resolved_tier`, + `position_cursor`, `status`, `outcome`, `run_uuid` nullable, `node_id` + nullable, `opened_at`, `closed_at`) with indexes on + `(anchor_object_uuid, template_id)` and `(status, template_id)`. Add + `sequence_uuid` + `sequence_position` to `openregister_tasks` with an index + on `(sequence_uuid, sequence_position)`. Add `legacy_step_id` to + `openregister_tasks` and `migrated_task_uuid` to + `openregister_approval_steps` — the reconciliation pair. Add + `correlation_key` to `openregister_flow_runs` with an index on + `(status, correlation_key)`. +2. **Templates.** For every row in `openregister_approval_chains`, create one + task template named by the chain's `name`, bound to its `schemaId`, with + one position per entry in its `steps` JSON carrying that entry's `role` and + `order`. Guarded on existence, so a re-run creates no second template. +3. **Sequences and tasks.** For every distinct `(chain_id, object_uuid)` in + `openregister_approval_steps`, open one sequence from that chain's template + and convert each step to a task at the step's `stepOrder`: `role` → + candidate group with performer type `group`; `requesterId` → sequence + requester; `created` preserved; `pending` → enabled; `waiting` → + non-enabled; `approved` / `rejected` → terminal with the matching outcome. + Write `legacy_step_id` and `migrated_task_uuid` on both sides. +4. **Decision history.** For every terminal step, append a task audit entry + carrying `decidedBy`, `comment`, `decidedAt` and the outcome, attributed to + the recorded decider, flagged as migrated. A decider that no longer exists + keeps its recorded string. +5. **Sequence status.** Close each sequence: rejected if any step was + rejected; completed if every step was approved; running otherwise, with its + cursor at the ordinal that was pending. A `(chain, object)` set with no + pending and no waiting step and no approval at all is closed as terminated, + not left running. +6. **Freeze the legacy rows.** Stamp `migrated_task_uuid` on every step and + record the cutover timestamp in app config where an operator will find it. + The tables keep their data; the code that could write them is gone in the + same release, so they are undecidable by construction rather than by a + flag. +7. **Verification, in the migration, failing loudly.** Every non-terminal step + has exactly one non-terminal task; every chain has exactly one template; no + `(object, template)` has two running sequences; every migrated task's + ordinal equals its step's `stepOrder`; the count of enabled tasks equals + the count of steps that were `pending`. Any mismatch names the chain, the + object and the step, and the migration stops. +8. **Idempotency.** Steps 2-6 are each guarded on the reconciliation columns, + so a second run creates no template, no sequence, no task and no audit + entry. +9. **Rollback.** Before the first post-cutover decision: redeploy the previous + app version; the legacy tables are intact and the retired engine resumes + over them. After the first post-cutover decision: run the reverse repair + step shipped with this change, which writes migrated tasks' decisions back + onto their step rows for the fields the old schema can express and REPORTS + the fields it cannot (performer type, on-behalf-of, mandate, per-entry + audit), then redeploy. The tables are NOT dropped by this change, and + dropping them MUST NOT be part of any rollback path. +10. **Follow-ups, named and not done here**: `filinq: + migrate-signing-to-or-tasks`; `hermiq: retire-agentflow-object-store`; + `openconnector: retire-hitl-runner-to-or-tasks`; per-app leaf migrations + for procest, decidiq, pipelinq, planix and buildiq; the hydra-gates + anti-pattern gate; the drop-legacy-approval-tables migration; and the + scheduled-filter dialect repair covering openconnector's two rules that + spell `op` instead of `operator` (ConductionNL/openregister#2787). + +## Open Questions + +- **Where `consumedAt` lands.** openconnector's `approval_request` carries a + consumed marker so an approved-but-unconsumed request cannot silently + re-authorize a later run; the schema fragment's own `$comment` explains it. + Provisionally it becomes a `consume` verb on the task audit, referenced by + the work that relied on the approval. Deferrable: it changes nothing in this + change's specs or tasks — the requirement here is only that a home exists + and is named before the runner retires — and the shape is best fixed by the + openconnector migration that has the call sites in front of it. +- **Whether the sequence gains an explicit skip verb.** Several fleet + approvals want "this position is not required for this instance" (an absent + approver, a delegated authority already exercised). Provisionally NO for + this change: skipping is a change to the ordering contract, and adding it to + a retirement makes behaviour-preservation unprovable. Deferrable because + adding a verb later changes no stored shape. +- **Whether the retirement inventory should be a spec fixture or generated.** + Provisionally a checked-in fixture the test asserts against, because it must + fail when openconnector's schema changes. Deferrable: it changes no + requirement, only where the test reads from. diff --git a/openspec/changes/flow-approval-consolidation/proposal.md b/openspec/changes/flow-approval-consolidation/proposal.md new file mode 100644 index 0000000000..1f152b93da --- /dev/null +++ b/openspec/changes/flow-approval-consolidation/proposal.md @@ -0,0 +1,286 @@ +--- +kind: code +depends_on: [flow-business-timers, flow-user-task-node] +--- + +# Proposal: flow-approval-consolidation + +## Summary + +Retire OpenRegister's own approval engine onto the task service. The +`ApprovalChain` / `ApprovalStep` pair, `ApprovalService`, the two listeners, +the four events, the nine REST routes and the two Vue components go away; +what a schema declares stays exactly where it is, and is executed by +`TaskService` through a new ordered **task sequence**. There is **no facade +period** — ADR-098 Decision 1 says full migration, and the measurement below +says a facade would not have saved the one app that consumes the events +anyway. + +This change also writes down the contract the leaf apps migrate against, so +the anti-pattern gates that `consume-or-approval-workflow-fleet-wide` and +`consume-or-workflow-engine-fleet-wide` proposed can finally be switched on +against something that exists. + +## Why + +**OpenRegister ships two human-work engines, and the older one is a subset of +the newer one.** `ApprovalStep` (`lib/Db/ApprovalStep.php:62-136`) is eleven +columns: uuid, chainId, objectUuid, stepOrder, role, status, decidedBy, +comment, decidedAt, created, requesterId. Every one of them has a home in +`openregister_tasks` — `flow-task-entity`'s own proposal says so in as many +words ("Shape-wise this is `ApprovalStep` … given a nullable `run_uuid` + +`node_id`"). What `ApprovalStep` has that the task does not is **ordering**: +`stepOrder` plus the `waiting` → `pending` promotion at +`ApprovalService.php:193-204`. That is the whole of the delta, and it is +twenty lines. + +**Everything else the chain does, the task does better.** The step's +authorization is one line — `isInGroup($userId, $role)` +(`ApprovalService.php:412-413`) — with no claim, no delegation, no +`on_behalf_of`, no candidate pool, no routing strategy and no audit row: the +decision is written onto the step itself, destructively +(`ApprovalService.php:174-178`). There is no inbox: "what am I being asked to +approve?" is `GET /api/approval-steps` filtered by role +(`appinfo/routes.php:1238`), which knows nothing about any other kind of work +a person owes. There is no deadline of any kind — the chain has no `dueAt`, +no `expiresAt`, no escalation and no sweep, so an approval that nobody +answers stays pending until the heat death of the register. + +**The role performer is the one thing worth keeping, and it is the fleet's +only one.** `ApprovalStep.role` (`lib/Db/ApprovalStep.php:90`) stores a +Nextcloud group NAME resolved to people at decision time, not a uid. +`flow-task-entity`'s performer model was written to hold exactly this +(spec.md, "The performer model spans people, groups, agents and workers": +*"a ROLE name resolved to people at authorization time — +`lib/Db/ApprovalStep.php:81-87` stores a role, not a uid"*). Consolidation is +therefore not a capability trade; it is a strict superset, provided the +migration actually carries the role across. + +**A facade would protect one app, and would not even do that.** The four +events (`ApprovalStepInitiatedEvent`, `…Approved`, `…Rejected`, +`…Completed`) have exactly ONE registered subscriber in the whole fleet: +filinq/docudesk registers all four onto a single listener +(`../docudesk/lib/AppInfo/SigningEventRegistrar.php:64-67` → +`../docudesk/lib/EventListener/ApprovalStepListener.php`). And that listener +does not merely observe — its own docblock (`:20-23`) says the signing +provider *"is then responsible for calling `ApprovalService::approveStep` / +`rejectStep` back, closing the loop."* filinq consumes the events **and** +drives the service. An event shim would re-emit four events into an app whose +reply path had been deleted underneath it: a shim that reports success while +the loop stays open. So the events are REMOVED, with a named migration, and +filinq moves in its own change. + +**The declarative surface is a different thing from the runtime, and it must +not move.** `x-openregister-approval-chains` on a schema is what fleet apps +actually author against; `ApprovalChainGateListener` REFUSES the gated +transition until every provisioned step is approved +(`lib/Listener/ApprovalChainGateListener.php:186-262`), fail-closed even when +the chain cannot be provisioned (`:200-206`). Deleting the annotation along +with the tables would turn every declared gate in the fleet into an open +door, silently, on upgrade. So the annotation stays, byte-identical, and only +what executes it changes — which is also ADR-031's default path: declarative +first, imperative only where the dialect cannot reach. + +**And there is a harvest deadline.** ADR-065 relocates openconnector's +runner. Its `approval_request` schema +(`../openconnector/lib/Settings/register.d/hitl-approval-rule-action.json`) +is the fleet's only source of `expiresAt` + `onTimeout: error|skip|dead_letter` ++ `onReject: error|skip|dead_letter`, swept at 300s by +`../openconnector/lib/Cron/ApprovalTimeoutSweepJob.php:53` into +`ApprovalService::sweepExpired()` +(`../openconnector/lib/Service/ApprovalService.php:638-681`). +`flow-business-timers` already harvested the timer half. What it did not take +is `onReject` (a decision outcome, not a clock) and `consumedAt` (an +approved-but-unconsumed request must not silently re-authorize a later run — +the schema fragment's own `$comment` says so). Those two have to land +somewhere before the runner moves, and this is the change that owns +"nothing was lost". + +**hermiq mirrored the flow definition into an object store and it drifted.** +`agentflow` (`../hermiq/lib/Settings/hermiq_register.json:3589-3590`) and +`agentflowrun` (`:3678`) hold `nodes`, `edges`, `limits`, `trigger`, `cron`, +`enabled` — the same fields as `openregister_flows`. hermiq's own manifest +records the outcome (`../hermiq/src/manifest.json:1248`): *"a duplicate mirror +of the native flow rows — so the list showed objects while the engine ran the +native rows, free to drift."* The index has since been re-pointed at +`/api/flows?app=hermiq`, but the schemas are still declared and +`SeedHydraTriageFlow.php:114` still carries `FLOW_SCHEMA = 'agentflow'` as a +vestige while the repair itself writes through `FlowMapper` (`:331`). A +mirror that is merely unused today is a mirror that will be used again. + +## What Changes + +- **`ApprovalChain`, `ApprovalStep` and their mappers are RETIRED**, together + with `lib/Service/ApprovalService.php` (464L), + `lib/Controller/ApprovalController.php` (361L), the nine routes at + `appinfo/routes.php:1231-1240`, `src/components/workflow/ApprovalChainPanel.vue`, + `src/components/workflow/ApprovalStepList.vue`, and the four + `lib/Event/ApprovalStep*Event.php` classes. **BREAKING** for any caller of + `/api/approval-chains` or `/api/approval-steps` and for any subscriber to + the four events. +- **An ordered task sequence** — `openregister_task_sequences` + + `TaskSequenceService` — supplies the only thing the chain had that the task + does not: `stepOrder`, promotion of the next task when one completes, and + termination of the remainder when one is rejected. It is the task + equivalent of `ApprovalService.php:193-204`, made explicit, authorized and + audited. It is NOT flow-specific: a sequence with no `run_uuid` is + first-class, exactly as a task with no run is. +- **The declarative surface is preserved verbatim and re-pointed.** + `x-openregister-approval-chains` keeps its shape — `transition`, + `approvers[].role`, `amountField` + `minAmount` tiers, + `separationOfDuties`, `onApprove: advanceTransition`, `statusOnApprove` / + `statusOnReject`. `ApprovalChainAnnotationInstaller` becomes a compiler + that provisions a task TEMPLATE instead of an `ApprovalChain` row; + `ApprovalChainGateListener` keeps refusing the transition, and asks the + SEQUENCE whether it is complete instead of asking `ApprovalStepMapper`. + **No schema in any app is edited by this change.** +- **The role performer survives as a first-class case**, not as a string + copied into a comment: a migrated step becomes a task with + `performer_type: group`, the role as its candidate group, and the + `single-role` routing strategy — the mapping `flow-task-entity`'s performer + requirement was written to accept. +- **Separation of duties is kept and hardened.** `verifySeparationOfDuties()` + (`ApprovalService.php:334-352`) defaults to ON for a declared chain and + refuses a decision by the recorded requester. It moves into the sequence's + authorization, where it also survives delegation: an `on_behalf_of` that + resolves to the requester is the same self-decision, and today's check + would miss it because delegation does not exist today. +- **A full data migration with an in-flight contract.** Every chain becomes a + template; every step becomes a task; every non-terminal step becomes a + non-terminal task at the same position with the same role, requester and + age. The old rows are left in place, marked migrated, and **made + undecidable** — the code that could decide them is gone in the same + release. Verification is specified, not assumed: counts reconcile, no + in-flight approval is lost, and no approval can be decided twice. +- **The four events are REMOVED with a named replacement.** Consumers move to + the task lifecycle events from `flow-task-entity` plus the sequence's own + `TaskSequenceCompletedEvent`. The mapping is written down per event, and + filinq's migration (`filinq: migrate-signing-to-or-tasks`) is named as the + follow-up that consumes it. +- **`AwaitSignalNode` stays, and gains a correlation key.** The division of + labour is the one `flow-user-task-node` already stated — a signal is for a + system that will call back, a task is for a performer who must be found. + What a system calling back cannot do today is say WHICH run it is calling + about without knowing a run uuid: `POST /api/flow-runs/{uuid}/resume` + (`appinfo/routes.php:1312`) is addressed by run uuid and by nothing else, + and `configKeys()` (`AwaitSignalNode.php:180`) has no correlation field. + "The vote closed", "the batch settled", "the provider finished" are events + that carry a business key, not a run uuid. A `correlationKey` config plus a + key-addressed signal endpoint closes that, fail-closed on ambiguity. +- **The openconnector HITL retirement contract**: an inventory of the six + semantics its `approval_request` carries, each with the named home it lands + in (`flow-business-timers` for the clock, here for `onReject` and + `consumedAt`, `flow-task-entity` for the approver group and the comment), + and a verification that the runner cannot be retired while any of them is + homeless. +- **The migration contract + anti-pattern gate for leaves**: what a leaf app + MUST stop shipping (its own step-routing engine, its own approver-group + resolution, a schema mirroring flow-definition fields, a stored `overdue`) + and what it calls instead. Enforced by a Hydra gate that fails on the + retired OR routes and event classes as well as on the shapes. +- **hermiq's `agentflow` mirror is contract-retired here, deleted there.** + This change states the rule — an app contributes node types through + `RegisterFlowNodesEvent` and resolves definitions from the OR flow entity, + never from an object schema of its own. Removing the two schemas is + `hermiq: retire-agentflow-object-store`. + +## What does NOT change + +- **The task entity, the user-task node and the timers themselves.** + `flow-task-entity`, `flow-user-task-node` and `flow-business-timers` are + dependencies, not scope. This change adds no task column, no performer + type, no lifecycle value, no node and no timer semantic. Where it needs one + that does not exist, it says so in DEFERRED_QUESTIONS rather than inventing + it here. +- **Per-app leaf migrations.** procest (`parafeeractie`, the parafering + engine), decidesk/decidiq (`DecisionTransitionGuard`, `WorkflowService`), + pipelinq, planix, openbuild/buildiq, filinq (signing) and openconnector's + runner each migrate in a change in their own repo. This change ships the + target and the contract; it edits no leaf app. +- **CMMN case semantics.** Stages, sentries, milestones and discretionary + items are `flow-cmmn-case-semantics`. A sequence here is ordinal, not a + case plan. +- **BPMN interchange.** Mapping an approval onto `bpmn:userTask` on import + and back out is the existing `flow-bpmn-interchange` change. +- **`x-openregister-approval-chains` itself.** Not extended, not renamed, not + deprecated. Extending it — per-step deadlines, parallel steps, delegation + policy — is a later change, deliberately not bundled with a retirement. +- **openconnector's two dead scheduled notification rules.** They use `op` + instead of `operator` and are silently inert (issue + ConductionNL/openregister#2787, which covers 24 fleet rules across three + invented dialects). Named here so the retirement does not carry them + forward as if they worked; FIXED in the dialect change, not in this one. + +## Capabilities + +### New Capabilities +- `flow-approval-consolidation`: the ordered task sequence and its + advance/reject/terminate semantics; the declarative gate re-pointed onto + it; the chain-and-step retirement with its data migration and verification; + the event replacement mapping; the `await-signal` correlation key and the + signal-versus-task division; the leaf migration contract and its + anti-pattern rules; the openconnector HITL and hermiq `agentflow` + retirement contracts. + +### Modified Capabilities +- `approval-workflow`: every requirement describing the chain/step RUNTIME — + chain CRUD, step listing, step decisions, the four events and the + workflow-execution history rows — is REMOVED, because the runtime is + removed. The requirements describing the DECLARATIVE surface + (`x-openregister-approval-chains`, the transition gate, threshold routing, + separation of duties, auto-advance) are restated against the task sequence, + because their observable behaviour is preserved and a schema author must + not have to notice. + + + +## Impact + +- **Affected specs**: new `flow-approval-consolidation`; `approval-workflow` + loses its runtime requirements and keeps its declarative ones. +- **Affected code, removed**: `lib/Db/ApprovalChain.php` (185L), + `ApprovalChainMapper.php` (189L), `ApprovalStep.php` (208L), + `ApprovalStepMapper.php` (268L), `lib/Service/ApprovalService.php` (464L), + `lib/Controller/ApprovalController.php` (361L), the four + `lib/Event/ApprovalStep*Event.php`, `src/components/workflow/ApprovalChainPanel.vue`, + `src/components/workflow/ApprovalStepList.vue`, and their unit tests. +- **Affected code, rewritten**: `lib/Listener/ApprovalChainGateListener.php` + (380L — the refusal stays, the store it consults changes), + `lib/Listener/ApprovalChainAdvanceListener.php` (127L — subscribes to the + sequence's completion instead of `ApprovalStepCompletedEvent`), + `lib/Service/ApprovalChainAnnotationInstaller.php` (188L — compiles to a + task template), `src/views/schemas/SchemaWorkflowTab.vue:42` (mounts the + task-sequence panel instead of `ApprovalChainPanel`). +- **Affected code, new**: `lib/Db/TaskSequence.php` + + `TaskSequenceMapper.php`; `lib/Service/Task/TaskSequenceService.php`; + `lib/Event/TaskSequenceCompletedEvent.php`; the correlation-key resolution + on `lib/Service/Flow/FlowRunService.php` and one new route beside + `appinfo/routes.php:1312`; one migration with a repair step. +- **Affected APIs**: `/api/approval-chains` (5 routes) and + `/api/approval-steps` (3 routes) are REMOVED + (`appinfo/routes.php:1231-1240`). Chains and steps are read and decided + through `flow-task-entity`'s task and inbox routes. One route is added for + correlation-addressed signal delivery. +- **Affected apps**: filinq/docudesk is the only app that breaks at deploy + (`SigningEventRegistrar.php:64-67`) and is named as a follow-up. procest, + decidiq, pipelinq, planix, buildiq and openconnector are migration TARGETS + of this contract and are not touched here. opencatalogi and softwarecatalog + declare no approval chain and are unaffected — asserted by a test, not by + reading. +- **Depends on**: `flow-user-task-node` (the human step in a graph, and the + cancellation propagation a rejected sequence reuses) and + `flow-business-timers` (an approval with no deadline is what we are + replacing; the harvested `expiresAt`/`onTimeout` land there). Both + transitively depend on `flow-task-entity` and `flow-definition-versioning`. +- **ADRs**: ADR-098 D1 (one engine — the consolidation half), D2 (native task + entity), D3 (performer types — a role is a group performer, an agent may + hold an approval step), D6 (versioning first); ADR-065 (openconnector's + runner relocates, so its semantics are harvested before it moves); ADR-022 + (apps consume OR abstractions — this change makes the rule enforceable); + ADR-031 (declarative-vs-imperative — argued in design.md); ADR-005 + (fail-closed authorization); ADR-001 (seed data — none introduced, argued + in design.md). diff --git a/openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md b/openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md new file mode 100644 index 0000000000..547a7b1130 --- /dev/null +++ b/openspec/changes/flow-approval-consolidation/specs/approval-workflow/spec.md @@ -0,0 +1,344 @@ +## REMOVED Requirements + +### REQ-001: Approval chain CRUD + +**Reason**: The `openregister_approval_chains` table, the `ApprovalChain` +entity and the five `/api/approval-chains` routes +(`appinfo/routes.php:1232-1237`) are removed. A chain configuration is now a +task template, authored declaratively on the schema and administered through +the task-template surface `flow-task-entity` already owns. There is no +second CRUD surface for the same thing. + +**Migration**: A chain is declared as `x-openregister-approval-chains` on its +schema, which is unchanged and is the path every fleet chain already uses. +A chain that exists ONLY as a hand-created row (no schema declaration) is +migrated to a task template by the data migration in this change and is +edited afterwards through the task-template API. No caller in the fleet reads +`/api/approval-chains`; the two Vue components that did +(`src/components/workflow/ApprovalChainPanel.vue`, +`ApprovalStepList.vue`) are removed in the same release. + +### REQ-002: Track object progress through an approval chain + +**Reason**: `GET /api/approval-chains/{id}/objects` answered "which objects +are somewhere in this chain" by scanning step rows for one chain. The task +inbox answers the same question across every kind of work a person or a group +owes, which is the whole reason `flow-task-entity` exists. + +**Migration**: Query the task inbox filtered by the sequence's template and by +anchor. Per-object progress is the sequence's own position, which is a single +read rather than an aggregate over step rows. + +### REQ-003: List and filter approval steps + +**Reason**: `GET /api/approval-steps` (`appinfo/routes.php:1238`) is a +role-filtered list of one work type. It is replaced by the task inbox, which +is the same query without the "one work type" restriction and with +claim/unclaim, delegation and derived overdue that the step list never had. + +**Migration**: "Pending steps for role X" becomes the unclaimed-pool inbox +query for candidate group X. "All steps for object Y" becomes the tasks-on- +object query. Both are `flow-task-entity` routes. + +### REQ-004: Initialize approval chain steps for an object + +**Reason**: `ApprovalService::initializeChain()` +(`lib/Service/ApprovalService.php:103-138`) created one row per step with the +first `pending` and the rest `waiting`. Sequence provisioning replaces it and +is specified in `flow-approval-consolidation`. + +**Migration**: The observable behaviour is preserved and restated in +`flow-approval-consolidation` — "A sequence enables exactly one position at a +time". The first position is enabled, later positions are created and NOT +enabled, and the caller is the same gate listener. + +### REQ-005: Approve or reject a pending step with role enforcement + +**Reason**: The decision verbs move onto the task service, where they are +authorized fail-closed against the full performer model rather than by the +single `isInGroup($userId, $role)` check at +`lib/Service/ApprovalService.php:412-413`, and where the decision is appended +to an immutable audit instead of overwriting the row it decides +(`ApprovalService.php:174-178`). + +**Migration**: `POST /api/approval-steps/{id}/approve` becomes task +completion with an approving outcome; `.../reject` becomes task completion +with a rejecting outcome and a mandatory comment. Role enforcement is +preserved as a `group` performer with the role as candidate group, so the +same people can decide the same work. **BREAKING** for any direct caller of +the two routes. + +### Requirement: The system MUST dispatch a typed event when an approval step transitions to `pending` + +**Reason**: `ApprovalStepInitiatedEvent` describes a row in a table that no +longer exists. Its one fleet subscriber +(`../docudesk/lib/AppInfo/SigningEventRegistrar.php:64`) also calls +`ApprovalService::approveStep()` back to close the loop +(`../docudesk/lib/EventListener/ApprovalStepListener.php:20-23`), so +re-emitting the event without the reply path would report a working +integration that cannot answer. + +**Migration**: Subscribe to the task lifecycle event for a task becoming +enabled, filtered by the sequence's template. The per-event replacement +mapping is normative in `flow-approval-consolidation`. filinq migrates in +`filinq: migrate-signing-to-or-tasks`. + +### Requirement: The system MUST dispatch a typed event when an approval step is approved + +**Reason**: As above — the event carries an `ApprovalChain` and an +`ApprovalStep`, both removed. + +**Migration**: Subscribe to task completion with an approving outcome. The +`statusOnApprove` the event carried is resolved by the sequence and is +readable from the sequence record; `nextStep` is the sequence's newly enabled +position. + +### Requirement: The system MUST dispatch a typed event when an approval step is rejected + +**Reason**: As above. + +**Migration**: Subscribe to task completion with a rejecting outcome. The +`statusOnReject` the event carried is resolved by the sequence. + +### Requirement: The system MUST dispatch a typed event when an approval chain completes + +**Reason**: As above. + +**Migration**: Subscribe to `TaskSequenceCompletedEvent`, which is dispatched +at exactly the same moment — the last position completing with an approving +outcome — and carries the sequence, the final task, the deciding identity and +the resolved `statusOnApprove`. + +### Requirement: The system MUST preserve existing approval engine behaviour + +**Reason**: This requirement froze `ApprovalService`'s pre-existing behaviour, +including the `workflow_executions` history row written by +`persistApprovalExecution()` (`lib/Service/ApprovalService.php:428-462`), +which is written fail-soft inside a `try`/`catch` that only warns. The engine +it protects is removed. + +**Migration**: Decision history is the task audit, which is append-only and +records the acting identity, the performer type, the on-behalf-of identity +and the mandate — none of which the `workflow_executions` row carried. The +data migration in this change writes the historical decisions of migrated +steps into the task audit so no decided approval loses its provenance. + +## MODIFIED Requirements + +### REQ-006: A schema MAY declare `x-openregister-approval-chains` provisioning an approval chain + +A schema's `configuration` block MAY declare `x-openregister-approval-chains`, +a map of chain-key → chain spec. Each chain spec MUST name a `transition` +matching an action key in the same schema's +`x-openregister-lifecycle.transitions`, and MUST declare `approvers` (a +non-empty list of `{role, min}`, optionally `minAmount` per entry plus a +top-level `amountField` for threshold-tier routing). + +**The declared shape SHALL NOT change.** A schema that declared a chain +before this change SHALL declare it identically after, and SHALL require no +edit of any kind. What changes is only what the declaration provisions: on +`SchemaCreatedEvent` / `SchemaUpdatedEvent` the system SHALL upsert a **task +template** (named by the chain key, bound to the schema, with one ordered +position per `approvers` entry carrying that entry's role) instead of an +`ApprovalChain` row. Schemas without this key SHALL be unaffected. + +`x-openregister-approval-chains` SHALL remain registered in the schema +annotation vocabulary. A configuration write drops any `x-openregister-*` key +absent from that whitelist, so removing the registration would make every +declared gate in the fleet silently inert — an open door rather than a +refusal. + +Provisioning SHALL be idempotent: saving an unchanged schema repeatedly SHALL +converge on one template, and SHALL NOT create a second template, a second +position, or a new template version. + +#### Scenario: The declared key survives a save round-trip +- **GIVEN** a schema whose `configuration` declares `x-openregister-approval-chains` +- **WHEN** the schema is saved and re-read +- **THEN** the configuration MUST still contain the `x-openregister-approval-chains` key, byte-identical to what was submitted +- @e2e exclude annotation-vocabulary persistence — covered by schema unit tests + +#### Scenario: Declaring a chain provisions a task template without manual CRUD +- **GIVEN** a schema declares `x-openregister-approval-chains` with one chain entry naming two approver roles +- **WHEN** the schema is saved (create or update) +- **THEN** a task template MUST exist named by the chain key and bound to the schema +- **AND** it MUST carry two ordered positions whose candidate groups are the two declared roles, in declaration order +- @e2e exclude provisioning contract — covered by installer unit tests + +#### Scenario: Re-saving an unchanged schema provisions nothing new +- **GIVEN** a schema whose declared chain has already been provisioned +- **WHEN** the schema is saved again with the same declaration +- **THEN** exactly one template MUST exist for that chain key +- **AND** no new template version MUST be created +- @e2e exclude idempotency — covered by installer unit tests + +### REQ-007: A transition named by a declared chain MUST be blocked until its steps are all approved + +When an object's lifecycle transition matches a chain's declared `transition`, +the system SHALL REFUSE the object write with the error code +`approval-chain-pending` unless the object's approval **sequence** for that +chain is complete with an approving outcome. The refusal SHALL be fail-closed: +a chain that is declared but cannot be provisioned SHALL refuse the transition +with `approval-chain-misconfigured`, and SHALL NOT let it through. + +On the first attempt, with no sequence for (chain, object), the system SHALL +provision the sequence — enabling its first position and creating the rest — +before refusing. A second attempt while the sequence is still running SHALL +NOT provision a second sequence and SHALL NOT create a duplicate task at any +position. + +A sequence terminated by a rejection SHALL be closed rather than deleted, and +the next attempt SHALL open a NEW sequence. The rejected sequence, its tasks, +its comments and its audit SHALL remain readable afterwards — today the +rejected cycle's step rows are DELETED +(`lib/Listener/ApprovalChainGateListener.php:232`), which destroys the record +of who refused and why at the moment somebody resubmits. + +The error codes `approval-chain-pending` and `approval-chain-misconfigured` +SHALL be unchanged, because leaf apps and UIs match on them. + +#### Scenario: First attempt provisions a sequence and is refused +- **GIVEN** an object with no approval sequence attempts a gated transition +- **WHEN** the save is processed +- **THEN** a sequence MUST be provisioned with its first position enabled and later positions not enabled +- **AND** the write MUST be refused with error code `approval-chain-pending` +- **AND** the object's lifecycle field MUST NOT change +- @e2e exclude gate refusal — covered by gate-listener unit tests + +#### Scenario: A second attempt while running does not duplicate work +- **GIVEN** an object whose approval sequence is running +- **WHEN** the transition is attempted again before the sequence completes +- **THEN** no second sequence and no duplicate task MUST be created +- **AND** the attempt MUST be refused again with `approval-chain-pending` +- @e2e exclude idempotency of the gate — covered by gate-listener unit tests + +#### Scenario: A rejected cycle is closed, preserved, and reopened on the next attempt +- **GIVEN** an object whose approval sequence was terminated by a rejection +- **WHEN** the gated transition is attempted again +- **THEN** a NEW sequence MUST be provisioned and the attempt MUST be refused with `approval-chain-pending` +- **AND** the rejected sequence, the rejecting decision, its comment and its actor MUST still be readable +- @e2e exclude resubmission history — covered by sequence-service unit tests + +#### Scenario: A declared chain that cannot be provisioned fails closed +- **GIVEN** a schema declaring a chain whose approvers resolve to no usable role +- **WHEN** the gated transition is attempted +- **THEN** the write MUST be refused with error code `approval-chain-misconfigured` +- **AND** the transition MUST NOT be allowed through +- @e2e exclude fail-closed policy — covered by gate-listener unit tests + +### REQ-008: Threshold routing selects a single approver tier by amount + +When a chain spec declares `amountField`, provisioning SHALL select the single +`approvers` entry with the highest `minAmount` that is +`<= object[amountField]`, and SHALL provision the sequence from that entry +alone rather than from every declared tier. When `amountField` is absent, +provisioning SHALL use every declared entry in order, unchanged. + +The tier SHALL be resolved once, at provisioning time, from the object data +being written, and SHALL be frozen onto the sequence. A later edit to the +amount SHALL NOT silently re-route a running sequence to a different approver +— the sequence records which tier it was opened under, and a changed amount is +a new attempt, not a live re-route. + +#### Scenario: Low-amount object routes to the lower tier +- **GIVEN** a chain spec with tiers `{role: finance-clerks, minAmount: 0}` and `{role: finance-directors, minAmount: 100000}` +- **WHEN** a gated transition is attempted on an object with `amount = 5000` +- **THEN** the provisioned sequence MUST have exactly one position, with candidate group `finance-clerks` +- @e2e exclude tier selection — covered by threshold-routing unit tests + +#### Scenario: High-amount object routes to the higher tier +- **GIVEN** the same chain spec +- **WHEN** a gated transition is attempted on an object with `amount = 250000` +- **THEN** the provisioned sequence MUST have exactly one position, with candidate group `finance-directors` +- @e2e exclude tier selection — covered by threshold-routing unit tests + +#### Scenario: The resolved tier is frozen onto the sequence +- **GIVEN** a running sequence opened under the `finance-clerks` tier +- **WHEN** the object's amount is changed to a value that would resolve to `finance-directors` +- **THEN** the running sequence MUST keep its `finance-clerks` position +- **AND** the recorded tier MUST still name `finance-clerks` +- @e2e exclude frozen-tier rule — covered by sequence-service unit tests + +### REQ-009: Decisions MUST enforce separation of duties when declared + +When a chain's schema declares a matching `x-openregister-approval-chains` +entry, the system SHALL REFUSE a decision whose deciding identity is the +sequence's recorded requester, unless the entry explicitly sets +`separationOfDuties: false`. Absent the key, separation of duties SHALL +default to ON — an unstated policy on an approval is the safe one, not the +permissive one. + +The check SHALL be evaluated against the identity that is actually deciding +AND against the identity being acted for. A delegated decision whose +`on_behalf_of` resolves to the requester SHALL be refused on the same +grounds; a self-decision routed through a delegate is the same self-decision. +Delegation does not exist in the retired engine, so this is the one place +where the migrated behaviour is deliberately stricter than what it replaces. + +The refusal SHALL be distinguishable from an authorization failure: a +requester who is also a member of the candidate group is authorized and is +still refused, and the reason SHALL say which of the two applied. + +A sequence with no recorded requester SHALL NOT be refused on these grounds. + +#### Scenario: Requester cannot decide their own sequence +- **GIVEN** a sequence whose requester is `alice`, under a schema declaring the default `separationOfDuties` +- **AND** `alice` is a member of the position's candidate group +- **WHEN** `alice` attempts to complete the enabled task with an approving outcome +- **THEN** the decision MUST be refused with a reason naming separation of duties, not authorization +- **AND** the task MUST remain non-terminal +- @e2e exclude authorization rule — covered by sequence-authorization unit tests + +#### Scenario: A delegate cannot decide on the requester's behalf +- **GIVEN** the same sequence, and a delegate acting with `on_behalf_of` set to `alice` +- **WHEN** the delegate attempts to complete the enabled task +- **THEN** the decision MUST be refused on separation-of-duties grounds +- @e2e exclude delegation loophole — covered by sequence-authorization unit tests + +#### Scenario: An opted-out chain permits the requester to decide +- **GIVEN** a schema declaring `separationOfDuties: false` for the chain +- **WHEN** the requester, who is in the candidate group, completes the enabled task +- **THEN** the decision MUST be accepted +- @e2e exclude explicit opt-out — covered by sequence-authorization unit tests + +### REQ-010: A completed chain MUST auto-advance the gated transition when declared + +When an approval sequence completes with an approving outcome for a chain +whose schema declares `onApprove: advanceTransition`, the system SHALL apply +the declared `transition` to the sequence's anchor object in the same request +as the completing decision. A chain with no matching declaration, or a +declared `onApprove` other than `advanceTransition`, SHALL NOT advance +anything. + +The auto-advance SHALL remain fail-soft in the same sense it is today +(`lib/Listener/ApprovalChainAdvanceListener.php:110-121`): a transition that +throws SHALL leave the sequence correctly completed and the object at its +pre-gate state, SHALL be logged with the action and the object, and SHALL NOT +re-open, re-run or invalidate the approval. A failure to advance SHALL NOT be +reported to the deciding user as a failure to approve — the approval +succeeded. + +A subsequent manual attempt at the same transition SHALL be allowed through, +because the sequence is complete and the gate has nothing left to refuse. + +#### Scenario: Completion auto-advances the parent transition +- **GIVEN** a sequence whose final position is approved, completing it +- **AND** the schema declares `onApprove: advanceTransition` for that chain +- **WHEN** the completion is processed +- **THEN** the declared transition MUST be applied to the anchor object in the same request +- **AND** the object's lifecycle field MUST reach the transition's declared target state +- @e2e exclude auto-advance wiring — covered by advance-listener unit tests + +#### Scenario: A chain without the declaration does not auto-advance +- **GIVEN** a sequence for a chain with no `onApprove: advanceTransition` declaration +- **WHEN** it completes +- **THEN** no transition MUST be applied +- @e2e exclude negative case — covered by advance-listener unit tests + +#### Scenario: A failing auto-advance does not undo the approval +- **GIVEN** a completing sequence whose declared transition is refused by a lifecycle guard +- **WHEN** the auto-advance is attempted +- **THEN** the sequence MUST remain completed with its approving outcome +- **AND** the failure MUST be logged naming the action and the object +- **AND** a later manual attempt at the same transition MUST pass the approval gate +- @e2e exclude fail-soft advance — covered by advance-listener unit tests diff --git a/openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md b/openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md new file mode 100644 index 0000000000..0d25c2acf9 --- /dev/null +++ b/openspec/changes/flow-approval-consolidation/specs/flow-approval-consolidation/spec.md @@ -0,0 +1,503 @@ +## Purpose + +Retires OpenRegister's own approval-chain engine onto the task service by +giving tasks an ordered sequence, migrating every existing chain and +in-flight step onto it without losing or double-deciding an approval, and +writing down the contract every leaf app and the retiring openconnector +runner migrate against. + +## ADDED Requirements + +### Requirement: An approval is an ordered task sequence with one position enabled at a time + +The system SHALL provide a **task sequence**: an ordered set of positions, +each holding at most one task, with a recorded requester, a recorded outcome +and an anchor naming the object the approval is about. + +A sequence SHALL enable exactly ONE position at a time. Provisioning SHALL +create every position and enable only the first; completing the enabled +position with an approving outcome SHALL enable the next, in the same request +as the completing decision. A sequence whose last position completes with an +approving outcome SHALL itself complete with an approving outcome. + +A sequence SHALL NOT require a flow run. `run_uuid` and `node_id` SHALL be +optional provenance on a sequence exactly as they are on a task: a sequence +opened by a schema-declared gate has neither, and is first-class. + +Position order SHALL be stable and SHALL NOT be inferred from creation +timestamps, task ids or inbox ordering. Two positions SHALL NOT share an +ordinal within one sequence. + +A sequence SHALL be terminal in exactly one way per outcome — `completed` with +an approving outcome, `rejected`, or `terminated` — and SHALL NOT be +re-opened. A further attempt at the same approval SHALL open a NEW sequence. + +#### Scenario: Provisioning enables only the first position + +- **GIVEN** a template with three ordered positions +- **WHEN** a sequence is provisioned from it +- **THEN** three tasks MUST exist +- **AND** exactly one of them, at the first position, MUST be enabled +- **AND** the other two MUST be non-terminal and not enabled +- @e2e exclude sequence provisioning contract — covered by TaskSequenceService unit tests + +#### Scenario: An approving decision enables the next position in the same request + +- **GIVEN** a running sequence whose first of three positions is enabled +- **WHEN** that task is completed with an approving outcome +- **THEN** the second position MUST be enabled before the completing request returns +- **AND** the third position MUST still not be enabled +- @e2e exclude in-request advance — covered by TaskSequenceService unit tests + +#### Scenario: The last approving decision completes the sequence + +- **GIVEN** a running sequence on its final position +- **WHEN** that task is completed with an approving outcome +- **THEN** the sequence MUST become terminal with an approving outcome +- **AND** no further position MUST be enabled +- @e2e exclude terminality — covered by TaskSequenceService unit tests + +#### Scenario: A sequence with no run is first-class + +- **GIVEN** a sequence provisioned by a schema-declared gate, with no run uuid and no node id +- **WHEN** it is provisioned, advanced and completed +- **THEN** every step MUST behave identically to a sequence created from a flow +- @e2e exclude fleet-generic requirement — covered by unit tests without a run + +### Requirement: A rejection terminates the sequence and every task it still owns + +A rejecting outcome at any position SHALL terminate the sequence, and SHALL +terminate every non-terminal task the sequence owns — the enabled one and +every later position — with a reason naming the rejecting position. Those +tasks SHALL disappear from every inbox as actionable work in the same request. + +A rejecting outcome SHALL require a comment. A rejection submitted without one +SHALL be REFUSED, and the sequence SHALL remain running. + +A terminated sequence, its tasks, its decisions, its comments and its audit +SHALL remain readable indefinitely. The system SHALL NOT delete them when a +new attempt opens, and SHALL NOT overwrite them. + +A rejection SHALL NOT be an error condition. It is a recorded outcome of a +process that worked, and SHALL be reported as such to every caller. + +#### Scenario: Rejection at the first position terminates the later ones + +- **GIVEN** a running sequence with three positions and the first enabled +- **WHEN** the first task is completed with a rejecting outcome and a comment +- **THEN** the sequence MUST become terminal as rejected +- **AND** the second and third tasks MUST be terminated with a reason naming the first position +- **AND** none of the three MUST appear as actionable in any inbox +- @e2e exclude propagation — covered by TaskSequenceService unit tests + +#### Scenario: A rejection without a comment is refused + +- **GIVEN** a running sequence with an enabled task +- **WHEN** a rejecting outcome is submitted with an empty comment +- **THEN** the decision MUST be refused +- **AND** the task MUST remain enabled and non-terminal +- @e2e exclude mandatory-comment rule — covered by TaskSequenceService unit tests + +#### Scenario: A new attempt does not erase the rejected one + +- **GIVEN** a rejected sequence for an object +- **WHEN** a new sequence is opened for the same object and template +- **THEN** the rejected sequence and its decisions MUST still be readable +- **AND** the two sequences MUST be distinguishable by their open time +- @e2e exclude history preservation — covered by TaskSequenceService unit tests + +### Requirement: The role performer survives as a group performer with single-role routing + +A position declared by a role name SHALL become a task with performer type +`group`, the role as its candidate group, no assignee until someone claims it, +and the `single-role` routing strategy. The set of people who may decide a +migrated approval SHALL be exactly the set who could decide it before — +membership of that Nextcloud group, evaluated at decision time, not at +provisioning time. + +Role resolution SHALL happen when the decision is attempted, never frozen at +provisioning. A person added to the role group after a sequence opened SHALL +be able to decide its enabled position; a person removed SHALL NOT. + +A role that resolves to nobody SHALL leave the position unassigned in the +pool. It SHALL NOT be assigned to the requester, to an administrator, or to a +system identity, and the sequence SHALL NOT auto-advance past it. + +#### Scenario: A role position is a group task with no assignee + +- **GIVEN** a template position declaring the role `finance-clerks` +- **WHEN** a sequence is provisioned +- **THEN** its task MUST carry performer type `group` with candidate group `finance-clerks` +- **AND** it MUST have no assignee +- **AND** it MUST appear in the unclaimed inbox of every member of that group +- @e2e exclude performer mapping — covered by provisioning unit tests + +#### Scenario: Group membership is evaluated at decision time + +- **GIVEN** a running sequence whose enabled position names a role group +- **WHEN** a user is added to that group after the sequence was provisioned +- **THEN** that user MUST be able to decide the enabled task +- @e2e exclude late-membership rule — covered by authorization unit tests + +#### Scenario: An empty role assigns nobody and advances nothing + +- **GIVEN** an enabled position whose role group has no members +- **WHEN** the sequence is inspected +- **THEN** the task MUST be unassigned in the pool +- **AND** the sequence MUST NOT advance past that position +- @e2e exclude empty-pool rule — covered by routing unit tests + +### Requirement: The chain and step runtime is removed, and a migrated approval cannot be decided twice + +`ApprovalChain`, `ApprovalStep`, their mappers, the approval service, the +approval controller, the `/api/approval-chains` and `/api/approval-steps` +routes and the four `ApprovalStep*` events SHALL be REMOVED. No facade, +adapter, alias or deprecation shim SHALL be left behind for any of them. + +After the migration there SHALL be exactly ONE decision surface for a migrated +approval. The system SHALL NOT expose a code path, route, console command or +event handler that can decide a migrated step through the retired engine, and +SHALL NOT accept a decision on a migrated step row. + +A decision recorded on a migrated task SHALL NOT be recordable a second time +through any surface. Deciding an already-terminal task SHALL be REFUSED with a +reason naming its terminal state. + +Removal SHALL leave no orphan: no route entry pointing at a removed +controller method, no service registration for a removed class, no event +listener registration for a removed event, and no Vue import of a removed +component. + +#### Scenario: The retired routes are gone + +- **GIVEN** a deployed instance after this change +- **WHEN** any of the nine retired approval routes is requested +- **THEN** the request MUST NOT reach a controller +- **AND** no route entry MUST name a removed controller method +- @e2e exclude route-reachability contract — covered by the route-reachability gate and Newman + +#### Scenario: A migrated approval decided once cannot be decided again + +- **GIVEN** a migrated task that was completed with an approving outcome +- **WHEN** any surface attempts to decide it again +- **THEN** the attempt MUST be refused with a reason naming the terminal state +- **AND** the recorded decision, actor and time MUST be unchanged +- @e2e exclude double-decision guard — covered by TaskService unit tests + +#### Scenario: No shim survives the removal + +- **GIVEN** the source tree after this change +- **WHEN** it is searched for the removed class names and route paths +- **THEN** the only matches MUST be in migration and repair code that reads the legacy tables +- @e2e exclude static assertion — covered by a repository-wide grep test + +### Requirement: Every in-flight approval survives the migration at the same position + +The migration SHALL convert every approval chain into a task template and +every approval step into a task, and SHALL be idempotent: running it twice +SHALL produce the same tasks, the same sequences and no duplicates. + +For every chain and object with at least one non-terminal step, the migration +SHALL open ONE sequence whose enabled position is the step that was pending, +at the same ordinal, with the same role, the same requester and the same +creation time. A step that was waiting SHALL become a non-enabled position at +its own ordinal. No in-flight approval SHALL be dropped, completed, +re-started, re-notified or re-assigned by the migration. + +A chain and object whose steps are all terminal SHALL migrate as a terminal +sequence, and SHALL NOT reappear as work in anyone's inbox. + +Every migrated step SHALL record the task it became, and every migrated task +SHALL record the step it came from, so the two sets can be reconciled by +count and by identity rather than by inspection. + +The legacy tables SHALL NOT be dropped by this change. They SHALL be left in +place, marked migrated, and unreachable by any decision path. + +The migration SHALL verify itself and SHALL FAIL LOUDLY rather than report a +partial success: every non-terminal step has exactly one non-terminal task, +every chain has exactly one template, no object has two running sequences for +one template, and no migrated task is enabled at an ordinal other than the one +its step held. + +#### Scenario: A pending step becomes the enabled position + +- **GIVEN** a chain of three steps for an object where step 2 is pending and step 3 is waiting +- **WHEN** the migration runs +- **THEN** one running sequence MUST exist with three positions +- **AND** position 2 MUST be enabled with the same role, requester and creation time as the step +- **AND** position 3 MUST exist and MUST NOT be enabled +- @e2e exclude data migration — covered by migration tests over a seeded database + +#### Scenario: Running the migration twice changes nothing + +- **GIVEN** a database already migrated +- **WHEN** the migration runs again +- **THEN** the task, sequence and template counts MUST be unchanged +- **AND** no task MUST change its lifecycle state +- @e2e exclude idempotency — covered by migration tests + +#### Scenario: A fully decided chain does not come back as work + +- **GIVEN** a chain whose steps for an object are all approved +- **WHEN** the migration runs +- **THEN** the resulting sequence MUST be terminal +- **AND** none of its tasks MUST appear as actionable in any inbox +- @e2e exclude terminal migration — covered by migration tests + +#### Scenario: A migration that cannot reconcile fails loudly + +- **GIVEN** a database where a non-terminal step cannot be mapped to exactly one task +- **WHEN** the migration's verification runs +- **THEN** it MUST fail with a message naming the chain, the object and the step +- **AND** it MUST NOT report success +- @e2e exclude verification behaviour — covered by migration tests with corrupted fixtures + +### Requirement: A decided approval keeps its decision, its actor and its comment + +Every already-decided step SHALL migrate its decision into the task audit: the +outcome, the deciding identity, the comment and the decision time, recorded as +an audit entry attributed to the original decider and marked as migrated. + +The migration SHALL NOT attribute a historical decision to the migrating +administrator, to a system identity, or to the current session. A decision +whose decider is no longer a known user SHALL keep the recorded identity +string; it SHALL NOT be blanked and SHALL NOT block the migration. + +The task audit SHALL be the decision history after this change, and it SHALL +carry what the retired `workflow_executions` row did not: the performer type, +the on-behalf-of identity where one applies, and the mandate relied on. + +#### Scenario: A historical decision keeps its decider + +- **GIVEN** a step approved by `alice` with a comment on a past date +- **WHEN** the migration runs +- **THEN** the task audit MUST carry an entry attributed to `alice` with that comment and that date +- **AND** it MUST be marked as migrated +- @e2e exclude provenance migration — covered by migration tests + +#### Scenario: A decision by a departed user still migrates + +- **GIVEN** a decided step whose decider no longer exists as a user +- **WHEN** the migration runs +- **THEN** the audit entry MUST keep the recorded identity string +- **AND** the migration MUST NOT fail on that row +- @e2e exclude departed-user case — covered by migration tests + +### Requirement: The four approval events are replaced by a named, complete mapping + +The system SHALL dispatch `TaskSequenceCompletedEvent` when a sequence +completes with an approving outcome, carrying the sequence, the final task, +the deciding identity and the resolved approving status. It SHALL be +dispatched at exactly the moment the retired completion event was. + +The remaining three retired events SHALL be replaced by task lifecycle events +from the task capability, filtered by sequence: a position becoming enabled +replaces the initiated event; a task completing with an approving outcome +replaces the approved event; a task completing with a rejecting outcome +replaces the rejected event. + +The mapping SHALL be published as migration documentation naming, for each +retired event, the replacement event, the replacement for every field the +retired event carried, and the ordering guarantee between them. A field with +no replacement SHALL be named as such rather than omitted. + +No retired event SHALL be re-emitted, aliased or wrapped. A consumer that is +not migrated SHALL stop receiving events at deploy, visibly, rather than +receive events it can no longer answer. + +#### Scenario: Sequence completion is observable + +- **GIVEN** a running sequence on its final position +- **WHEN** that task is completed with an approving outcome +- **THEN** `TaskSequenceCompletedEvent` MUST be dispatched with the sequence, the final task, the decider and the approving status +- @e2e exclude event contract — covered by sequence event unit tests + +#### Scenario: An unmigrated consumer fails visibly, not silently + +- **GIVEN** an app registering a listener for a retired approval event +- **WHEN** the app is loaded after this change +- **THEN** the registration MUST fail visibly at load rather than register a listener that never fires +- @e2e exclude deliberate breakage — covered by an integration test with a stale registration + +### Requirement: The signal node keeps machine-to-machine work and gains a correlation key + +`openregister.await-signal` SHALL remain available for systems that call back, +and SHALL keep its heartbeat, its nudge-is-not-an-answer rule and its opt-in +fail-on-reject. Human and agent work belongs on the user-task node; the two +SHALL NOT answer each other. + +An await-signal step SHALL be able to declare a `correlationKey` — a business +key resolved from the run's items or context at suspension time — and the +system SHALL accept a signal addressed by that key instead of by run uuid. A +caller that knows "the vote on proposal X closed" SHALL NOT need to know a run +uuid to say so. + +Correlation resolution SHALL be fail-closed. A key matching NO suspended run +SHALL be refused as not found, and SHALL NOT be queued, buffered or replayed +against a run that suspends later. A key matching MORE THAN ONE suspended run +SHALL be refused as ambiguous, and SHALL NOT wake any of them; the system +SHALL NOT pick one. + +A correlation-addressed signal SHALL NOT be able to complete a task, claim a +task, or advance a run suspended on a user-task node. It carries the same +authority as the existing run-uuid signal and no more. + +A correlation key SHALL be recorded on the run so an operator can see what a +suspended run is waiting to be told, without reading a JSON column by hand. + +#### Scenario: A signal addressed by business key wakes the right run + +- **GIVEN** a run suspended on an await-signal step with correlation key `vote:proposal-42` +- **WHEN** a signal is delivered for that key +- **THEN** that run MUST be signalled +- **AND** no other suspended run MUST be affected +- @e2e exclude signal addressing — covered by signal-resolution unit tests + +#### Scenario: An ambiguous key wakes nothing + +- **GIVEN** two suspended runs carrying the same correlation key +- **WHEN** a signal is delivered for that key +- **THEN** the delivery MUST be refused as ambiguous +- **AND** both runs MUST remain suspended +- @e2e exclude ambiguity guard — covered by signal-resolution unit tests + +#### Scenario: An unmatched key is refused, not held + +- **GIVEN** no suspended run carrying the key `vote:proposal-99` +- **WHEN** a signal is delivered for that key +- **THEN** it MUST be refused as not found +- **AND** a run that later suspends with that key MUST NOT receive it +- @e2e exclude no-replay rule — covered by signal-resolution unit tests + +#### Scenario: A correlated signal cannot decide a human task + +- **GIVEN** a run suspended on a user-task node, and a correlation key resolving to it +- **WHEN** a signal is delivered for that key +- **THEN** the task MUST remain non-terminal and the run MUST remain suspended +- @e2e exclude authority boundary — covered by mixed-flow unit tests + +### Requirement: A human-in-the-loop semantic is not retired until it has a named home + +Before the openconnector approval runner is retired, every semantic its +`approval_request` carries SHALL have a named home in this fleet's task, +sequence or timer capability, and that mapping SHALL be published as a +retirement inventory. + +The inventory SHALL cover at minimum: the approver group; the requester +identity; the approver comment; the enforcing expiry and its timeout outcome; +the rejection outcome; and the consumed marker that prevents an approved but +unconsumed request from silently re-authorizing a later run. + +A semantic with no home SHALL block the retirement. It SHALL NOT be dropped, +and it SHALL NOT be recorded as "handled by the task entity" without naming +the field or verb that handles it. + +The rejection outcome vocabulary SHALL be preserved with the meaning its +declaration always promised, including the outcome that skips rather than +errors — the retired implementation collapsed two of its three declared +outcomes onto one behaviour, and the migration SHALL NOT carry that defect +forward as if it were the contract. + +An approved decision that has been consumed SHALL NOT authorize a second +consumption. Consumption SHALL be recorded on the decided work, and a second +attempt to rely on the same approval SHALL be refused. + +#### Scenario: The retirement inventory is complete before the runner moves + +- **GIVEN** the retirement inventory for the openconnector approval runner +- **WHEN** it is checked against the `approval_request` declaration +- **THEN** every declared property MUST name either a target field or verb, or an explicit decision not to carry it with a reason +- @e2e exclude documentation contract — covered by a fixture test over the inventory + +#### Scenario: The skip outcome skips + +- **GIVEN** a rejected or expired approval declaring the skipping outcome +- **WHEN** the outcome is applied +- **THEN** the subject MUST be skipped +- **AND** it MUST NOT be recorded as an error +- @e2e exclude harvested semantic — covered by outcome unit tests + +#### Scenario: A consumed approval cannot authorize twice + +- **GIVEN** an approving decision that has already been consumed by the work it authorized +- **WHEN** a later run attempts to rely on the same approval +- **THEN** the attempt MUST be refused +- @e2e exclude idempotency of authorization — covered by consumption unit tests + +### Requirement: A leaf app consumes the task service and ships no approval engine of its own + +An app operating on OpenRegister-owned objects SHALL NOT ship its own +step-routing engine. Specifically it SHALL NOT implement: ordered approval +steps with its own advance-on-approval logic; its own approver-group or role +resolution; its own pending/approved/rejected state machine over an approval +object; or its own deadline sweep over approval rows. + +It SHALL instead provision a task template, open a sequence, and read its own +work through the task inbox. + +An app SHALL NOT store a derived overdue flag as a status value or as a +column. Overdue is computed from the clock against the advisory and enforcing +dates, and a stored copy is only as correct as the last job that remembered to +write it. + +An app SHALL NOT declare a schema that mirrors the fields of a flow +definition or a task. A mirrored store drifts from the store the engine +actually reads, and the drift is silent — the list shows one thing while the +engine runs another. + +These rules SHALL be mechanically enforceable, and the enforcement SHALL name +the retired OpenRegister classes and routes as well as the shapes, so an app +still calling the removed approval API fails the check rather than failing at +runtime. + +#### Scenario: A home-grown step engine is detected + +- **GIVEN** an app shipping an ordered-approval service with its own advance-on-approval logic +- **WHEN** the anti-pattern check runs against it +- **THEN** the check MUST report the finding with the file and the rule it broke +- @e2e exclude gate behaviour — covered by the gate's own fixture suite + +#### Scenario: A stored overdue flag is detected + +- **GIVEN** an app schema whose status enum contains an overdue value, or which declares an overdue property +- **WHEN** the anti-pattern check runs +- **THEN** the check MUST report it +- @e2e exclude gate behaviour — covered by the gate's own fixture suite + +#### Scenario: A call to a removed approval route is detected + +- **GIVEN** an app calling a retired approval chain or step route, or registering a listener for a retired approval event +- **WHEN** the anti-pattern check runs +- **THEN** the check MUST report it as a broken integration, not as a style finding +- @e2e exclude gate behaviour — covered by the gate's own fixture suite + +### Requirement: An app contributes node types and resolves flow definitions from the flow entity + +An app that extends the flow engine SHALL contribute node types through the +engine's node-registration event, and SHALL resolve flow definitions from +OpenRegister's flow entity. It SHALL NOT keep its own object-schema copy of a +flow definition's nodes, edges, limits, trigger or schedule. + +Where such a mirror exists, every surface that lists, edits, seeds or reads a +definition SHALL read the flow entity, and the mirror SHALL be retired in the +owning app rather than left declared-but-unused. A declared mirror is a mirror +that will be written to again. + +A definition resolved for a run SHALL be the pinned published version, so an +app's contributed nodes cannot change the graph a suspended run resumes on. + +#### Scenario: An app's flow list reads the flow entity + +- **GIVEN** an app that contributes node types and authors flows +- **WHEN** its flow list is loaded +- **THEN** the rows MUST come from the flow entity +- **AND** no object-schema mirror MUST be read +- @e2e exclude cross-app contract — covered by the contributing app's own tests + +#### Scenario: A declared definition mirror is a finding + +- **GIVEN** an app schema declaring nodes and edges alongside a trigger and a schedule +- **WHEN** the anti-pattern check runs +- **THEN** the check MUST report it as a flow-definition mirror +- @e2e exclude gate behaviour — covered by the gate's own fixture suite diff --git a/openspec/changes/flow-approval-consolidation/tasks.md b/openspec/changes/flow-approval-consolidation/tasks.md new file mode 100644 index 0000000000..8c06a79255 --- /dev/null +++ b/openspec/changes/flow-approval-consolidation/tasks.md @@ -0,0 +1,196 @@ +# Tasks: flow-approval-consolidation + +## 1. Storage + +- [ ] 1.1 Migration: create `openregister_task_sequences` (`uuid`, + `template_id`, `template_version`, `template_snapshot`, + `anchor_object_uuid`, `register_id`, `schema_id`, `chain_key`, + `requester_id`, `resolved_tier`, `position_cursor`, `status`, `outcome`, + `run_uuid` nullable, `node_id` nullable, `opened_at`, `closed_at`) with + indexes on `(anchor_object_uuid, template_id)` and + `(status, template_id)`; add `sequence_uuid` + `sequence_position` + + `legacy_step_id` to `openregister_tasks`; add `migrated_task_uuid` to + `openregister_approval_steps`; add `correlation_key` to + `openregister_flow_runs` with an index on `(status, correlation_key)`. + No table is dropped. +- [ ] 1.2 `lib/Db/TaskSequence.php` + `TaskSequenceMapper` (find by uuid, find + the running sequence for an anchor+template, list an anchor's sequences + newest-first, list positions in ordinal order). Ordinals are stable and + unique within a sequence — enforced by a unique index, not by the + service, because two writers provisioning the same gated write is the + concurrent case. + +## 2. The sequence + +- [ ] 2.1 `lib/Service/Task/TaskSequenceService.php`: provision (create every + position, enable only the first, freeze `template_snapshot` and + `resolved_tier`), advance (enable the next position in the SAME request + as the completing decision — the behaviour at + `lib/Service/ApprovalService.php:193-204`), reject (terminate the + sequence and every non-terminal task it owns with a reason naming the + rejecting position), and terminate. Each verb goes through + `TaskService`'s authorized lifecycle verbs; none writes a task row + directly. +- [ ] 2.2 Sequence authorization: separation of duties resolved from the + chain's declarative entry, defaulting to ON when the entry exists + (`ApprovalService.php:371-397`), evaluated against the acting identity + AND the `on_behalf_of` identity, refused BEFORE the performer check so + the reason is honest (`ApprovalService.php:165-168`); rejecting outcomes + require a comment. +- [ ] 2.3 `lib/Event/TaskSequenceCompletedEvent.php`, dispatched at the moment + the retired `ApprovalStepCompletedEvent` was — the final position + completing with an approving outcome — carrying sequence, final task, + decider and resolved approving status. + +## 3. The declarative surface, re-pointed + +- [ ] 3.1 `ApprovalChainAnnotationInstaller` compiles + `x-openregister-approval-chains` into a task TEMPLATE instead of an + `ApprovalChain` row (`:134-175`), idempotent so a re-save produces no + second template and no new template version. The annotation's declared + shape and its vocabulary registration are unchanged. +- [ ] 3.2 `ApprovalChainGateListener` asks the SEQUENCE whether the approval is + complete instead of scanning step rows (`:225-244`); keeps + `approval-chain-pending` and `approval-chain-misconfigured` verbatim; + keeps failing closed on an unprovisionable chain (`:200-206`); CLOSES a + rejected sequence and opens a new one instead of deleting rows + (`:232`); freezes the amount tier at provisioning + (`resolveStepsOverride()`, `:277-300`) so a mid-cycle amount edit cannot + re-route a running approval. +- [ ] 3.3 `ApprovalChainAdvanceListener` subscribes to + `TaskSequenceCompletedEvent`; the `onApprove: advanceTransition` lookup + and the fail-soft `TransitionEngine::transition()` call (`:110-121`) are + otherwise unchanged. + +## 4. Retirement + +- [ ] 4.1 Delete `lib/Db/ApprovalChain.php`, `ApprovalChainMapper.php`, + `ApprovalStep.php`, `ApprovalStepMapper.php`, + `lib/Service/ApprovalService.php`, + `lib/Controller/ApprovalController.php`, the four + `lib/Event/ApprovalStep*Event.php`, and the nine routes at + `appinfo/routes.php:1231-1240`. No facade, alias or deprecation shim. + Assert the removal left no orphan route entry, service registration or + listener registration. +- [ ] 4.2 Delete `src/components/workflow/ApprovalChainPanel.vue` and + `ApprovalStepList.vue`; `src/views/schemas/SchemaWorkflowTab.vue:42` + mounts the task-sequence panel instead. Deciding happens in the task + inbox, not in a schema tab. +- [ ] 4.3 Publish the event replacement mapping as migration documentation: + per retired event, the replacement, the replacement for EVERY field it + carried, the ordering guarantee, and any field with no replacement named + as such. Referenced by `filinq: migrate-signing-to-or-tasks`. + +## 5. Correlation + +- [ ] 5.1 `correlationKey` added to `AwaitSignalNode::configKeys()` + (`lib/Service/Flow/Nodes/AwaitSignalNode.php:180`) and its config form, + resolved from items/context and written to the run's indexed + `correlation_key` at suspension; one new route beside + `appinfo/routes.php:1312` delivering a signal by key, resolving + fail-closed (0 matches → not found and NOT buffered; >1 → ambiguous and + wakes nothing) with the same authority as `resume()` and no ability to + complete, claim or advance a user task. + +## 6. Data migration + +- [ ] 6.1 Repair step: chains → templates; each `(chain_id, object_uuid)` → + one sequence; each step → a task at its `stepOrder` with `role` as + candidate group and performer type `group`, `requesterId` as sequence + requester, `created` preserved, `pending` → enabled, `waiting` → + not enabled, `approved`/`rejected` → terminal; terminal steps append a + migrated task-audit entry attributed to the ORIGINAL decider with the + original comment and time. Reconciliation columns written on both sides; + every stage guarded on them so a second run changes nothing. +- [ ] 6.2 In-migration verification that FAILS LOUDLY: every non-terminal step + has exactly one non-terminal task; every chain has exactly one template; + no (object, template) has two running sequences; every migrated task's + ordinal equals its step's `stepOrder`; enabled-task count equals the + count of steps that were `pending`. Any mismatch names chain, object and + step and stops the migration. The cutover timestamp is recorded where an + operator will find it. +- [ ] 6.3 Reverse repair step for the post-cutover rollback path: write + migrated tasks' decisions back onto their originating step rows for the + fields the legacy schema can express, and REPORT the fields it cannot + (performer type, on-behalf-of, mandate, per-entry audit). Dropping the + legacy tables is NOT part of this change and MUST NOT be part of any + rollback path. + +## 7. Contracts + +- [ ] 7.1 openconnector HITL retirement inventory as a checked-in fixture with + a test asserting it covers every property of + `../openconnector/lib/Settings/register.d/hitl-approval-rule-action.json`: + approver group, requester, comment, `expiresAt` + `onTimeout` (to + `flow-business-timers`), `onReject` (to the sequence's rejecting + outcome, with `skip` given the behaviour its enum always promised and + `../openconnector/lib/Service/ApprovalService.php:662` never gave it), + and `consumedAt`. A property with no named home fails the test. +- [ ] 7.2 The leaf migration contract as the spec's enforceable rules, handed + to the hydra-gates anti-pattern gate: no home-grown step engine, no own + approver-group resolution, no stored `overdue`, no schema mirroring + flow-definition or task fields, and a hard finding for any call to a + removed approval route or listener registration for a removed event + class. Includes the flow-definition rule hermiq's + `agentflow`/`agentflowrun` (`../hermiq/lib/Settings/hermiq_register.json:3589,3678`) + violate; their removal is `hermiq: retire-agentflow-object-store`. + +## 8. Tests + +- [ ] 8.1 Sequence, gate and correlation tests: one position enabled at a + time; advance inside the completing request; rejection terminating every + remaining task; rejection without a comment refused; a delegated + self-approval refused; a frozen tier surviving a mid-cycle amount edit; + a rejected cycle still readable after resubmission; and the four + correlation cases (hit, ambiguous, unmatched-and-not-buffered, cannot + decide a user task). +- [ ] 8.2 Migration and regression: the repair over a seeded database with + in-flight, rejected and fully decided chains, run twice with identical + results; verification failing loudly on a corrupted fixture; the reverse + repair reporting what it cannot carry; and a full pass with opencatalogi + and softwarecatalog installed proving they declare no approval chain and + are unaffected — asserted, not assumed. + +## Acceptance criteria + +- No code path can decide a migrated approval through the retired engine. A + grep for the removed class names and route paths returns matches only in + migration and repair code. +- Every schema in the fleet that declared `x-openregister-approval-chains` + before this change declares it identically after, with no edit, and its gate + still refuses the transition it refused before. +- Every in-flight approval survives the migration at the same ordinal, with + the same role, requester and creation time, and appears in exactly one + inbox. The count of enabled tasks after migration equals the count of steps + that were `pending` before it. +- No approval can be decided twice. A second decision on a terminal task is + refused with a reason naming its terminal state. +- A rejected approval, its comment and its decider are still readable after a + resubmission opens a new sequence. +- The migration either completes with its verification passing or stops with a + message naming the chain, the object and the step. It never reports a + partial success. +- A correlation-addressed signal wakes exactly one run or none. It never picks + between two candidates and never completes a user task. +- Every property of openconnector's `approval_request` has a named home, or an + explicit recorded decision not to carry it. The runner cannot be retired + otherwise. +- `openregister.await-signal` behaves identically to before for every flow + that does not declare a correlation key. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- New PHP files carry `@license EUPL-1.2` and `@copyright 2026 Conduction B.V.` +- `@spec` annotations point at + `openspec/specs/flow-approval-consolidation/spec.md` and + `openspec/specs/approval-workflow/spec.md` anchors. +- References ADR-098 D1 (the consolidation half), D2, D3, D6; ADR-065; + ADR-022 (the rule this makes enforceable); ADR-031 (argued in design.md, not + assumed); ADR-005 (fail-closed authorization); ADR-001 (no seed data, argued + in design.md). +- No leaf app is edited. procest, decidiq, pipelinq, planix, buildiq, filinq, + hermiq and openconnector migrations are named as follow-ups and shipped + elsewhere. +- No partial hook for skip, quorum, parallel positions or per-step deadlines + is left behind. diff --git a/openspec/changes/flow-business-timers/.openspec.yaml b/openspec/changes/flow-business-timers/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/flow-business-timers/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/flow-business-timers/design.md b/openspec/changes/flow-business-timers/design.md new file mode 100644 index 0000000000..cee85d804f --- /dev/null +++ b/openspec/changes/flow-business-timers/design.md @@ -0,0 +1,680 @@ +# Design: flow-business-timers + +## Context + +See proposal.md — Why. The design-relevant state of the code today: + +- **There is no business clock in OpenRegister.** `grep -rli + "workingcalendar\|working-calendar\|businessDays" lib/` returns nothing. + Every fleet business-day calculator lives in a leaf app; this change adds + the first one to the platform. +- **The engine's two clocks are both wall-clock and both in-run.** + `WaitNode` resolves `for`/`until` through `strtotime()` + (`lib/Service/Flow/Nodes/WaitNode.php:212-240`) and suspends the run + (`:184-196`); `AwaitSignalNode` re-asks on a 15-minute default + (`lib/Service/Flow/Nodes/AwaitSignalNode.php:87`) clamped to a 5-minute + floor (`:98`). Neither is touched here. +- **The worker cadence is already 300s.** `FlowScheduleWorker` sets + `setInterval(seconds: 300)` (`lib/Cron/FlowScheduleWorker.php:59`); + `FlowRunWorker` sets a 60s FLOOR and says so in its own comment + (`lib/Cron/FlowRunWorker.php:163-172`). A new sweep does not need a new + cadence. +- **The declarative scheduled-notification path exists and works.** + `lib/BackgroundJob/ScheduledNotificationJob.php` runs at 60s (`:105`), + reads `trigger.type === 'scheduled'` (`:205`), enforces a 60s minimum + interval (`:210`) and evaluates `trigger.filter` through + `ScheduledFilterEvaluator::matches()` (`:392`). Its dedup fingerprint is + built from `dedupeFields` or, failing that, from the filter's own field + keys (`ScheduledNotificationJob.php:477-505`). +- **`flow-task-entity` already ships the columns this change interprets.** + `openregister_tasks` carries `due_at`, `expires_at`, `sla_value`, + `sla_unit`, `suspended_until` and `recurrence` as stored-but-uninterpreted + fields, by its own D-4, with the inbox index `(assignee, is_terminal, + due_at)`. Its cancellation listener on run terminality (D-8) is the hook + this change extends. +- **Seeding OR's own schemas is an established repair-step pattern.** + `lib/Repair/ImportCredentialBrokerRegister.php:104-118` decodes a + descriptor from `lib/Settings/` and calls + `ConfigurationService::importFromApp()` — explicitly NOT + `importFromFilePath()`, which rejects an absolute path (`:104-106`). + Repair steps are registered in `appinfo/info.xml:159-170`. + `lib/Settings/register.d/README.md:9-25` records that OR does not + deep-merge its own fragments; the repair step is the wiring that exists. + +## Goals / Non-Goals + +**Goals:** + +- One store that can answer "how much of this term remains" at any moment, + including while the term is suspended, without consulting a job log. +- Firing decided entirely from persisted state, so an instance that was down + for a week fires the right things once when it comes back. +- Business-day arithmetic that is correct in 2035, resolved from a named + calendar, and refused rather than downgraded when the name is unknown. +- A rung that fires once under two concurrent sweeps, decided by the + database rather than by a read-then-write. +- Enough of openconnector's enforcing semantics captured, with its two + measured faults corrected, that ADR-065 can retire its runner without + losing the only enforcing deadline the fleet has. + +**Non-Goals:** + +- **Delivery.** No channel, no template, no recipient uid resolution, no + notification dialect. A fire raises a named transition; the message is + `flow-task-inbox-projections`' problem. +- **Being right about a specific app's deadline.** The NL national calendar + and the 14/7/2/0 ladder are DEFAULTS, not law encoded in PHP. Nothing in + this change knows what a bezwaartermijn is. +- **Replacing the five leaf calculators.** Introducing an authoritative + sixth is the goal; deleting the other five is a per-app change. +- **Sub-minute precision.** The sweep is 300s; a timer that must fire + within seconds is a `WaitNode`, not a business timer. +- **Recurrence.** Deferred to the task (see Open Questions). + +## Decisions + +### D-1 — Declarative-vs-imperative decision (ADR-031) + +**The WHEN is imperative; the WHO-gets-told stays declarative. The +declarative boundary is not a preference here — it was measured, and the +measurement found a live rule that has never fired.** + +ADR-031's default for a scheduled notification is +`x-openregister-notifications` with `trigger.type: scheduled` plus a +`filter`, swept by `ScheduledNotificationJob`. That path is real and it +works. Applying it to a deadline shows exactly where it stops. + +**The canonical filter grammar.** `ScheduledFilterEvaluator` accepts a MAP +of `field => spec`, ANDed, where `spec` is a scalar (equality shortcut) or +`{operator, value}` with `operator` in `equals`, `notEquals`, `withinNext`, +`olderThan` (`ScheduledFilterEvaluator.php:43-48`). An unknown operator +fails closed with a debug log (`:164-168`). So "past its due date" IS +declarative — `{"dueDate": {"operator": "olderThan", "value": "PT0S"}}` +resolves through `:149-161` and needs no PHP. + +**The example that was supposed to prove otherwise does not run.** +shillinq's `ContractObligation.x-openregister-notifications.obligationDeadline` +(`../shillinq/lib/Settings/register.d/contract-lifecycle-management.json:698-720`) +declares: + +```json +{"all": [ + {"field": "status", "operator": "notIn", "values": ["done", "waived"]}, + {"field": "dueDate", "operator": "before", "value": "now"} +]} +``` + +`matches()` iterates the filter as `field => spec` (`:89-95`), so the only +field it sees is `all`. `entryMatches()` finds no `operator` key on that +array and takes the scalar-equality shortcut (`:117-119`), comparing +`$objectData['all']` — absent, therefore `null` — against the array. +`null === [...]` is false for every object, so the rule matches nothing and +has never sent anything. `notIn` does exist in the codebase, in +`createdFilterMatches()` (`AnnotationNotificationDispatcher.php:1686-1699`), +reachable only from a `created` trigger. `before` exists nowhere. + +shillinq's own repo already contains the diagnosis: the description at +`../shillinq/lib/Settings/register.d/shillinq-notifications.json:7` names +`{all:[{field,operator,…}]}` as "a non-canonical filter grammar with +operators (notIn, before) the canonical scheduled-filter grammar does not +know", and records rewriting the sibling ARInvoice rules. `obligationDeadline` +was not rewritten and is still live. This is the same class as decidesk's +`actionOverdue` filtering `taskStatus: 'overdue'` (ConductionNL/decidiq#845): +one filters a field nothing writes, the other uses a grammar nothing reads. +Both are enabled, both look configured, both fire nothing. + +**Three measured limits, and they are why the WHEN is imperative:** + +1. **The filter is a map keyed by field, so a field carries exactly one + predicate.** `status notIn [done, waived]` is two exclusions on one + field. `notEquals` takes one value, and a second `status` key cannot + exist in a JSON object. The canonical rewrite shillinq performed on its + siblings had to change the QUESTION — filter `lifecycleState equals + overdue` — not the spelling. +2. **The sweep has no memory of having answered.** Dedup is a fingerprint + over the watched fields (`ScheduledNotificationJob.php:477-505`), which + suppresses a repeat of the same STATE. A ladder is per-rung state: "the + 14-day rung has fired, the 7-day rung has not" is a fact about the + timer, and a schema annotation has nowhere to put it. procest needed a + `notificatiesVerstuurd` array on the instance + (`../procest/lib/Service/DeadlineEscalationService.php:123`) for exactly + this reason. +3. **A filter cannot subtract suspended time.** `olderThan` compares a + stored field to `now`. Under opschorting the answer depends on an + accumulator (D-2), not on any stored date. Making the declarative form + correct would mean materialising an "effective deadline" back onto the + object on every suspend and resume — a stored clock-derived field, which + is the defect this change and `flow-task-entity` both forbid. + +Plus the categorical one: an enforcing expiry TRANSITIONS its subject. A +notification annotation sends and returns. + +**So the imperative half is `FlowTimerService`, `WorkingCalendarService`, +`SlaCalculator`, `EscalationLadderService` and `FlowTimerWorker`** — calendar +arithmetic, concurrency control and sweep mechanics, which is the category +ADR-031 preserves for PHP, not a business rule a schema could have carried. + +**The declarative half is everything downstream of a fire.** Every fire — +rung or expiry — raises a NAMED transition carrying the rung's roles and +priority. `x-openregister-notifications`' `transition(action)` trigger +addresses it, the same seam `flow-task-entity` D-1 uses for task actions. +This change registers no channel and resolves no uid. The ladder itself is +DATA (seeded objects, D-10), not a `private const` — which is the second +half of the ADR-031 test and the one procest's `DEFAULT_MATRIX` +(`DeadlineEscalationService.php:46-51`) fails. + +**The fence:** `FlowTimerService` may not contain a business rule about what +a particular app's deadline MEANS. Every branch in it must be about time +arithmetic, calendar resolution, timer state or concurrency. A rung's +recipients, priority and message identity are data; a branch on +`if ($subjectType === 'bezwaar')` is a review failure. + +**Defect to file, not fix here:** shillinq `obligationDeadline` uses a dead +filter grammar and has never fired (`contract-lifecycle-management.json:701-720`). + +### D-2 — The store holds a budget and a suspension ledger, not a target instant + +Three models were considered. + +**A — a target timestamp, cancelled or overwritten.** openconnector's +`approval_request.expiresAt`. Rejected: there is no moment to add a +remainder to while a term is suspended, so "paused with 19 days left" is +unrepresentable. The spec makes this explicit. + +**B — procest's optimistic pre-extension.** On pause, push `endDateCurrent` +forward by the full requested pause duration; on resume, pull back the +unused part (`../procest/lib/Service/DeadlinePauseService.php:145-153`). +It works for the happy path and has three measured faults: +`$consumed = max(0, min($durationDays, $diff))` (`:148`) caps consumption at +the REQUESTED duration, so a suspension that over-runs its window yields +`$unused = 0` and the applicant silently loses the extra days; between pause +and resume `endDateCurrent` states a deadline that is not true and every +reader believes it; and `Y-m-d` strings with `->days` cannot express an +hours SLA at all. + +**C — a budget plus a suspension ledger (chosen).** The timer stores what +the term IS, and derives when it lands: + +- `anchor_at` — the resolved instant the term runs from (D-4); +- `budget_value` + `budget_unit` — the SLA, in its own unit; +- `consumed_value` — DECIMAL, in `budget_unit`, the sum of all COMPLETED + running segments; +- `running_since` — the instant the current running segment began; NULL + while suspended; +- `fire_at` — the projected fire instant; NULL while suspended. + +The arithmetic is two operations: + +``` +suspend(): consumed_value += calendar.measure(running_since, now, budget_unit) + running_since = NULL; fire_at = NULL; suspended_since = now +resume(): running_since = now + fire_at = calendar.add(now, budget_value - consumed_value, budget_unit) +``` + +Remaining, answerable at any moment including while suspended, is +`budget_value - consumed_value - (running_since ? calendar.measure(running_since, +now, budget_unit) : 0)`. + +The spec's weekend requirement falls out rather than being special-cased: +`calendar.measure()` in `businessDays` counts business days, so a suspension +spanning a weekend adds nothing to `consumed_value`, and `resume()` +re-projects forward across the calendar from the resume instant. + +**`fire_at` is a materialised derivation, and that is not the `overdue` +defect.** It is a pure function of `anchor_at`, `budget`, `consumed_value`, +`running_since` and the calendar — all of which change only on writes this +service controls. `overdue` is a function of the CLOCK, which changes +constantly and between writes. This is the same distinction +`flow-task-entity` D-1 draws for `is_terminal`. `fire_at` exists because the +sweep must be an index range scan (D-8); a repair check asserts the identity +holds for every armed timer, and the recomputation lives in one private +method that every mutating operation calls. + +`suspended_since` and `suspended_total_seconds` are evidence and reporting. +They are deliberately NOT inputs to the arithmetic — `consumed_value` already +excludes suspended time by construction, and giving suspension two +representations is how they drift. + +### D-3 — Four expiry outcomes, all expressed as task actions + +`purpose` is `due` or `expiry`. A `due` timer raises escalations and never +touches subject state. An `expiry` timer applies `on_expiry`, permitted only +when `legal_effect = 'wettelijk'` (D-5) and refused at arm time otherwise. + +openconnector declares `error | skip | dead_letter` +(`../openconnector/lib/Settings/register.d/hitl-approval-rule-action.json:90-95`) +and implements two: `error` and `skip` both fall through to +`status = 'expired'` (`../openconnector/lib/Service/ApprovalService.php:662`) +and only `dead_letter` branches (`:663-665`). The vocabulary is harvested +and completed: + +| `on_expiry` | subject | process | +|---|---|---| +| `skip` | terminal, outcome `skipped` | CONTINUES past the step | +| `error` | terminal, outcome `failed` | run fails | +| `dead_letter` | terminal, outcome `dead_letter` | parked for an operator | +| `transition:` | whatever that action's target state is | per the action | + +All four are applied as NAMED TASK ACTIONS through the task service, not as +direct writes from the timer. `skip`, `error` and `dead_letter` are three +RESERVED action names with fixed target states; `transition:` names +any action in the subject's own action set. One code path, one authorization +check, one audit trail — and the audit records that the actor was the timer, +which is the question "who closed my task" needs answered. Writing subject +state from the sweep was rejected for the same reason +`flow-task-entity` D-1 rejected object-API writes to a task: it would put a +second, unauthorised mutation path on the lifecycle. + +### D-4 — The anchor is stored, and a moved anchor supersedes rather than mutates + +`anchor_event` (a named event), `anchor_offset` + `anchor_offset_unit`, and +the resolved `anchor_at`. Storing only `fire_at` loses the fact that the +15th was derived from a window that can itself move. + +When the anchoring event moves: the current row goes to `superseded`, and a +successor row is inserted carrying `supersedes_uuid`, the recomputed +`anchor_at` and `fire_at`, and the predecessor's `consumed_value`. Mutating +in place was rejected — the history has to show what the deadline used to be +and why it changed, and a mutated row cannot. + +**Fired rungs across a supersession.** The spec requires that rungs already +fired are not re-fired unless the new deadline puts them back in the future. +Two shapes: key the fire ledger on a lineage id, or copy rows forward. +Copy-forward chosen — the successor inherits a fire row (marked `inherited`) +for every rung whose distance is still in the past under the NEW deadline, +and inherits nothing for rungs the new deadline pushed back into the future. +The lineage key was rejected because the uniqueness constraint would then +span rows whose deadlines differ, and "put it back in the future" would need +a DELETE against the evidence. Copy-forward keeps the unique index trivially +per-timer and leaves the predecessor's rows intact. + +### D-5 — One calendar: named, computed, refused rather than downgraded + +`WorkingCalendarService` resolves in a fixed order — the calendar named on +the timer, else the calendar configured for the subject's organisation, else +the seeded national default — and throws at ARM time when a named calendar +does not exist. There is no weekday-only fallback anywhere in the resolution +path, because that fallback is precisely the failure mode already live in +the fleet: `../procest/lib/Service/WorkQueueService.php:533-563` is +`$dow < 6` and nothing else, and it disagrees with the three procest +calculators beside it. + +**Non-working dates are computed, not tabulated.** A calendar declares +rules, and a rule is one of: a fixed month/day; an Easter offset (Easter +computed, as `../procest/lib/Service/Kcc/SlaCalculator.php:266` already +does — Goede Vrijdag −2, Paasmaandag +1, Hemelvaart +39, Pinkstermaandag ++50); or a fixed date with an observed-shift rule. The last one is not +theoretical: Koningsdag is 27 April, observed on the 26th when the 27th is a +Sunday. A tabulated list gets that wrong the first time someone extends it +by hand. + +A calendar consisting only of enumerated dates is REFUSED at validation, +because such a calendar has an expiry date. The live instance of that is +`../shillinq/lib/Lifecycle/SubmissionWindowGuard.php:74-104` — 27 literal +`Y-m-d` strings ending at `2027-12-26`, after which it silently degrades to +weekends-only and computes wrong deadlines while throwing nothing. Explicit +exception dates remain allowed ALONGSIDE rules, for a genuine one-off +closure; they cannot be the whole calendar. + +`hoursPerWorkingDay` is required on every calendar. It is not needed for the +D-6 comparison (which is done on instants) but it is needed for `extend()` +when the extension is expressed in a unit other than `budget_unit`, and for +reporting remaining time in one unit in an inbox that mixes hours-SLA and +business-day-SLA work. + +The five leaf calculators are untouched (proposal — What does NOT change). + +### D-6 — preBreach is compared on the timeline, not between two integers + +`EscalationRuleValidator.php:176-195` compares `$offset > $sla['value']` — +two raw integers whose units may differ. It therefore rejects a 24-hour +warning on a 2-calendar-day SLA and accepts a 5-business-day warning on a +48-hour SLA. Both errors are in the spec as scenarios. + +Rather than converting both to seconds — which requires a fudge factor for +`businessDays` and is wrong near a holiday cluster — both sides are RESOLVED +against the calendar onto the timer's own timeline and the INSTANTS are +compared: + +``` +valid ⇔ calendar.sub(fire_at, offset, offsetUnit) >= anchor_at +``` + +This is exact, needs no unit conversion, and gives the spec's two scenarios +the right verdicts by construction. `offsetUnit` gains `calendarDays` so it +matches the unit set `sla.unit` allows (`EscalationRuleValidator.php:53` +omits it today, so a calendar-day SLA has no calendar-day warning). + +**Config-time versus arm-time.** A `workflowTemplate` step config has no +anchor yet, so config-time validation resolves against a probe anchor and is +ADVISORY. Arm-time validation is authoritative and is the one that refuses, +with the anchor named in the error — because a rule can be valid on one +anchor and invalid on another when a holiday cluster or a DST boundary falls +inside the offset. Saying so in the error is better than pretending the +config-time verdict was final. + +An escalation rule without an SLA is refused at both points. + +### D-7 — The rung ledger is rows, and the rung is CLAIMED before the transition + +`openregister_flow_timer_fires`, UNIQUE on `(timer_uuid, rung_key)`. +procest proves the ledger is required — remove `notificatiesVerstuurd` +(`DeadlineEscalationService.php:123`) and a daily scan mails the manager +every day for a fortnight — and also proves why a JSON array is not enough: +`:123` reads the array and `:145-149` writes it back, so two overlapping +sweeps both read "unfired" and both send. A unique index moves the decision +into the database, where a second INSERT loses. + +**Ordering: claim first, then raise the transition.** The insert IS the +claim; a duplicate-key means another pass owns the rung and this one does +nothing. That makes a rung AT-MOST-once. The alternative — dispatch, then +record — is what procest does (`:145` logs, `:147-152` marks sent) and is +at-least-once across a crash between the two. At-most-once is right here +because delivery downstream has its own retry and its own idempotency claim +(`AnnotationNotificationDispatcher.php:1097`), so a lost transition is +recoverable, whereas a duplicated escalation to a manager is not recoverable +at all — it is the mail flood the ledger exists to prevent. + +The fire row records the TRANSITION RAISED, not "notified", because this +change does not know whether anything was delivered. procest's row says +notified and the only thing that happened was +`$this->logger->info('Procest termijn escalation dispatched', $payload)` +(`:145`) — a ledger asserting a delivery that never occurred. + +**A downtime gap fires the rungs it passed, in ladder order.** The sweep +selects every rung of the timer's ladder whose distance is now in the past +and which has no fire row, and processes them ascending by severity. It does +not collapse them into the most severe: the 14-day and the 7-day rung have +different recipients, and the handler who was never told at 14 days is the +person the ladder exists to reach. + +### D-8 — Two bounded range scans on the existing 300s cadence + +`FlowTimerWorker extends TimedJob` with `setInterval(seconds: 300)`, matching +`FlowScheduleWorker.php:59` rather than `FlowRunWorker`'s 60s floor +(`FlowRunWorker.php:172`) — a business timer's resolution is days, and 300s +is already finer than anything it measures. + +Expiry selection is `WHERE state = 'armed' AND fire_at <= :now ORDER BY +fire_at LIMIT :batch`, served by an index on `(state, fire_at)`. Every row +read is a row acted on. + +The shape being avoided is measured. `ApprovalService::sweepExpired()` asks +for 500 `pending` rows (`:638-647`) and filters `expiresAt` in PHP afterwards +(`:655-658`), while its own docblock promises "bounded to already-expired +rows only … no full-table scan" (`:628-631`). Past 500 pending requests an +expired one outside the page is never reached, and the job reports a clean +pass. + +**Escalation needs a second selector, and a naive one re-introduces the +scan.** A rung is due before the timer is, so an escalation query bounded on +`fire_at` would have to reach forward by the ladder's longest rung and then +discard. Instead `next_rung_at` — the instant of the next UNFIRED rung — is +materialised on the timer and indexed on `(state, next_rung_at)`. The sweep +does two bounded range scans. `next_rung_at` is recomputed whenever a rung +fires, the deadline moves, or the timer suspends or resumes; like `fire_at` +it is a derivation of stored inputs, maintained in the same private method. + +**Re-entrancy.** Each timer is processed in its own transaction. The rung +claim is the unique insert. The terminal claim is a conditional update — +`SET state = 'fired' WHERE uuid = ? AND state = 'armed'` — and zero affected +rows means another pass owns it, so the outcome is not applied twice. No +lock is held across the outcome, so a slow action cannot stall the pass. + +Counts logged are work performed. A pass that hits the batch limit logs +`truncated: true`, so a backlog is visible instead of looking like a clean +sweep. + +### D-9 — Cancellation rides the subject's terminality, in the same transaction + +`flow-task-entity` D-8 already terminates tasks from a listener on run +terminality and from an explicit service call. This change extends the same +path: when a subject becomes terminal, every non-fired timer bound to it is +cancelled with a reason, inside the transaction that made it terminal. A +separate listener firing afterwards was rejected — that is the window in +which an escalation goes out about work that is already done. + +Cancelled means `state = 'cancelled'` plus `cancel_reason` and +`cancelled_at`. Never deleted: "why did I stop getting reminders about this" +has to be answerable, and a `wettelijk` breach already recorded survives the +cancellation and the subject's completion (D-5, spec). + +Termination is idempotent, because run terminality can be observed more than +once — `flow-task-entity` D-8 names the stale-run reaper race for the same +reason. + +An armed timer whose subject is terminal or absent is an ORPHAN. A repair +check counts them and reports; it does not quietly cancel them, because a +non-zero count is a defect in whoever completed the subject. + +### D-10 — The task's `due_at`/`expires_at` become a projection of the timers + +`flow-task-entity` ships `due_at` and `expires_at` on `openregister_tasks` +with the inbox index `(assignee, is_terminal, due_at)`. The spec here says +the subject's dates are a PROJECTION of the timers that bear on it. Both +hold: the timer is authoritative, and the task columns are a denormalised +copy maintained by `FlowTimerService` inside the arm/suspend/resume/extend/ +supersede/cancel transaction. + +The alternative — the inbox joins the timer table per row — was rejected +because it undoes the one index the inbox badge depends on, and that badge +renders on every page. + +The direction is one-way and enforced: writing `due_at` directly on a task +does not create a timer, and the task write surface refuses those fields +once a timer owns the subject. Otherwise there are two writers and the +projection silently diverges from the arithmetic. `suspended_until` on the +task is display-only; the arithmetic never reads it. `recurrence` stays +uninterpreted (Open Questions). + +Where a subject carries several timers (the lattice — `none`, `servicenorm`, +`wettelijk` at three different moments), the projection is the EARLIEST +non-cancelled `due` timer into `due_at` and the earliest enforcing timer +into `expires_at`. The projection is lossy by design; the timer table is +where the lattice is read in full. + +## Data model + +`openregister_flow_timers` — one row per timer, nullable unless stated: + +| Group | Columns | +|---|---| +| Identity | `id` (PK), `uuid` (NOT NULL, unique), `title`, `metadata` (JSON) | +| Subject | `subject_type` (NOT NULL, `task\|object\|run`), `subject_uuid` (NOT NULL), `organisation` | +| Provenance | `run_uuid`, `node_id`, `app_id` | +| Purpose | `purpose` (NOT NULL, `due\|expiry`), `legal_effect` (NOT NULL, `none\|servicenorm\|wettelijk`), `on_expiry` | +| Anchor | `anchor_event`, `anchor_offset`, `anchor_offset_unit`, `anchor_at` (NOT NULL) | +| Budget | `budget_value` (NOT NULL, decimal), `budget_unit` (NOT NULL, `hours\|businessDays\|calendarDays`), `consumed_value` (NOT NULL, decimal, default 0), `running_since` | +| Derived | `fire_at`, `next_rung_at` | +| Calendar | `calendar_slug` | +| Ladder | `ladder_slug`, `escalation_rules` (JSON) | +| Suspension | `suspended_since`, `suspend_reason`, `suspended_total_seconds` (NOT NULL, default 0) | +| Extension | `extension_count` (NOT NULL, default 0), `extension_max` (NOT NULL, default 1) | +| Lifecycle | `state` (NOT NULL, `armed\|suspended\|fired\|cancelled\|superseded`), `supersedes_uuid`, `fired_at`, `breached` (NOT NULL bool, default false), `cancelled_at`, `cancel_reason` | +| Stamps | `created` (NOT NULL), `updated`, `created_by` | + +No `overdue`. No `days_overdue`. No `is_overdue`. Overdue is +`fire_at < now AND state = 'armed'`, computed on read, and a suspended timer +cannot satisfy it because `fire_at` is NULL while suspended — the spec's +"a suspended term is not overdue" scenario falls out of the column shape +rather than needing a guard. + +Indexes: `or_flowtimer_due_idx` on `(state, fire_at)`; +`or_flowtimer_rung_idx` on `(state, next_rung_at)`; +`or_flowtimer_subj_idx` on `(subject_type, subject_uuid, state)`; +`or_flowtimer_run_idx` on `(run_uuid)`; unique `or_flowtimer_uuid_idx` on +`(uuid)`. All within the 30-character index-name limit, matching +`or_flowtrig_match_idx` (`lib/Migration/Version1Date20260810140000.php:98`). + +`openregister_flow_timer_fires` — the dedup ledger: `id`, `timer_uuid` +(NOT NULL), `rung_key` (NOT NULL — the rung's stable identity, e.g. +`preBreach:14:calendarDays`), `fired_at` (NOT NULL), `transition_action`, +`recipient_roles` (JSON), `priority`, `inherited` (bool, D-4), `created`. +UNIQUE `or_flowtimfire_uq` on `(timer_uuid, rung_key)` — the constraint the +whole at-most-once argument rests on. + +`openregister_flow_timer_events` — append-only evidence: `id`, `timer_uuid` +(NOT NULL), `type` (`armed\|suspended\|resumed\|extended\|superseded\|fired\|breached\|cancelled`), +`actor`, `reason`, `prior_fire_at`, `new_fire_at`, `days_impact`, `basis` +(the legal ground, e.g. `Awb 4:15`), `created` (NOT NULL). Index +`or_flowtimev_timer_idx` on `(timer_uuid, created)`. No UPDATE and no DELETE +path exists, and the timer's cancellation does not cascade to it — the +suspension of a legal term is a decision that has to stay evidenced. + +## Seed Data (ADR-001) + +Two schemas and their default objects ship in a register descriptor under +`lib/Settings/`, imported by a repair step following +`lib/Repair/ImportCredentialBrokerRegister.php:104-118` — decode the JSON +and call `ConfigurationService::importFromApp()`, NOT `importFromFilePath()` +(`:104-106` records why), registered in `appinfo/info.xml` beside the +existing `Seed*` steps (`:159-170`). All UUIDs are nil placeholders; all +identifiers are obviously fake. + +**1. `working-calendar` — `nl-national`.** Rules, not a table, so it does +not expire (D-5): + +```json +{ + "uuid": "00000000-0000-0000-0000-000000000101", + "slug": "nl-national", + "title": "Nederland — nationale feestdagen", + "workingWeekdays": [1, 2, 3, 4, 5], + "hoursPerWorkingDay": 8, + "rules": [ + {"kind": "fixed", "month": 1, "day": 1, "name": "Nieuwjaarsdag"}, + {"kind": "easter", "offset": -2, "name": "Goede Vrijdag"}, + {"kind": "easter", "offset": 1, "name": "Tweede Paasdag"}, + {"kind": "fixed", "month": 4, "day": 27, "name": "Koningsdag", + "observedShift": {"whenWeekday": "sunday", "days": -1}}, + {"kind": "easter", "offset": 39, "name": "Hemelvaartsdag"}, + {"kind": "easter", "offset": 50, "name": "Tweede Pinksterdag"}, + {"kind": "fixed", "month": 12, "day": 25, "name": "Eerste Kerstdag"}, + {"kind": "fixed", "month": 12, "day": 26, "name": "Tweede Kerstdag"} + ], + "exceptions": [] +} +``` + +**2. `working-calendar` — `example-organisation`.** A per-organisation +override proving the resolution order is exercised by the seed rather than +only by a test: `nl-national`'s rules plus one enumerated `exceptions` entry +for a local closure day, and `hoursPerWorkingDay: 7`. + +**3. `escalation-ladder` — `nl-termijn-default`.** 14/7/2/0, matching +`DeadlineEscalationService::DEFAULT_MATRIX:46-51` value for value, so the +behaviour that is already proven in production is the default — and is data +an administrator can edit rather than a `private const`: + +```json +{ + "uuid": "00000000-0000-0000-0000-000000000102", + "slug": "nl-termijn-default", + "rungs": [ + {"key": "preBreach:14:calendarDays", "offset": 14, "offsetUnit": "calendarDays", + "notifyRole": ["handler"], "priority": "low", "message": "termijn-14d"}, + {"key": "preBreach:7:calendarDays", "offset": 7, "offsetUnit": "calendarDays", + "notifyRole": ["handler", "teamleader"], "priority": "medium", "message": "termijn-7d"}, + {"key": "preBreach:2:calendarDays", "offset": 2, "offsetUnit": "calendarDays", + "notifyRole": ["handler", "teamleader", "manager"], "priority": "high", "message": "termijn-2d"}, + {"key": "slaBreached:0", "offset": 0, "offsetUnit": "calendarDays", + "notifyRole": ["handler", "teamleader", "manager"], "priority": "critical", + "message": "termijn-overschreden", "openIncident": true} + ] +} +``` + +The `message` values are message IDENTITIES, resolved by the notification +subsystem. Nothing in this change renders them. + +## Migration Plan + +1. **Schema.** One migration creating `openregister_flow_timers`, + `openregister_flow_timer_fires` and `openregister_flow_timer_events` with + the indexes above. Additive only — no existing table is altered, so + nothing that runs today changes behaviour. +2. **No backfill.** Nothing points at the store yet, by design (proposal — + Affected apps: none). procest, openconnector and shillinq keep their own + deadline services running untouched; they migrate in + `flow-approval-consolidation`. Copying their live termijn instances here + would create two authorities for the same deadline during the window in + which both run. +3. **Seeds.** The repair step imports the descriptor idempotently + (`force: false`, as `ImportCredentialBrokerRegister.php:113-118`), so a + re-run does not duplicate the calendar or overwrite an administrator's + edit to the ladder. +4. **Rollback.** Drop the three tables. Because no other table gained a + column and no other code reads them, reverting the app code restores + exactly today's behaviour. The seeded objects are left in place on + rollback — deleting configuration on a downgrade is how a rollback + becomes a data loss. +5. **Verification after deploy.** Every armed timer satisfies + `fire_at = calendar.add(running_since, budget - consumed)` within one + second; no armed timer has a NULL `fire_at`; no suspended timer has a + non-NULL `fire_at` or `running_since`; no armed timer's subject is + terminal; `nl-national` resolves a correct 2035 Easter-derived date. + +## Risks / Trade-offs + +- **`fire_at` and `next_rung_at` are materialised derivations and can + drift** if a future code path writes a budget field without recomputing + them. → One private recomputation method, called by every mutating + operation; the repair check in Migration Plan step 5 asserts the identity; + a test asserts it after every operation in the state machine. +- **The task's `due_at`/`expires_at` projection can diverge from the + timers.** → Same mitigation shape `flow-task-entity` uses for + `is_terminal` and its candidate index: one write path maintains both + inside the transaction, the task write surface refuses the fields once a + timer owns the subject, and a test asserts agreement after each operation. +- **At-most-once (D-7) means a rung can be lost** if the process dies + between the claim insert and the transition being raised. → Accepted and + argued: downstream delivery has its own retry, a lost escalation is + visible in the timer's event log, and the alternative is a duplicated + manager escalation that cannot be un-sent. The event log makes a + claimed-but-unraised rung findable. +- **`businessDays` arithmetic is O(days) in the naive implementation**, and + an 8-week term over a 300s sweep with thousands of timers would walk the + calendar repeatedly. → `calendar.measure`/`calendar.add` memoise + non-working dates per (calendar, year) for the life of the pass, which is + the only place the cost concentrates. +- **A wrong seeded calendar is wrong everywhere at once** — that is the + cost of centralising what five apps did five ways. → Mitigated by the + computed-rules requirement (no expiry date), by unit tests asserting + several future years including a Koningsdag-on-Sunday, and by the + organisation-level override existing from day one so a disagreement does + not require a platform change. +- **A `wettelijk` timer can enforce, and enforcement closes someone's + work.** → Fenced three ways: only `wettelijk` may carry `on_expiry` + (D-5), the outcome is applied as a named task action through the normal + authorization and audit path (D-3), and the breach record is permanent so + the transition is never silent. +- **Introducing a sixth business-day calculator while five remain** leaves + the fleet temporarily MORE inconsistent. → Deliberate (proposal). The + sixth is the one with a spec, a calendar and tests; the per-app changes + delete the others, and this change's value is not realised until they do. +- **`extension_max` defaults to 1 and an override path exists.** → The + override is a distinct, separately authorized operation recorded as an + override, mirroring procest's supervisor mode + (`../procest/lib/Service/DeadlineExtensionService.php:126,228`). A single + `extend()` that takes a "force" flag was rejected: the flag becomes the + default caller within a release. + +## Open Questions + +- **Whether `recurrence` belongs on the timer or on the task.** + Provisionally the task's — shillinq declares + `recurrence: none|monthly|quarterly|annually` on the durable business + object (`../shillinq/lib/Settings/register.d/contract-lifecycle-management.json:617-629`) + and no PHP in shillinq reads it, so there is no observed behaviour to + copy. Deferrable: a recurring obligation spawns a new subject, and a new + subject arms a new timer, so nothing in this store changes either way. +- **Whether a calendar should carry working HOURS-of-day** as well as + working days, so an `hours` SLA armed at 16:00 lands at 09:00 rather than + overnight. Provisionally no — `hoursPerWorkingDay` covers the commensurable + arithmetic D-5 needs, and a time-of-day window is additive to the calendar + object without touching the timer table or the sweep. +- **Whether the ladder should be resolvable per subject TYPE** rather than + per timer and organisation. Provisionally no: `ladder_slug` on the timer + plus the organisation default covers the observed cases, and a type-level + default is a resolution-order change, not a schema change. diff --git a/openspec/changes/flow-business-timers/proposal.md b/openspec/changes/flow-business-timers/proposal.md new file mode 100644 index 0000000000..fdee96ccc9 --- /dev/null +++ b/openspec/changes/flow-business-timers/proposal.md @@ -0,0 +1,249 @@ +--- +kind: code +depends_on: [flow-task-entity] +--- + +# Proposal: flow-business-timers + +## Summary + +Give the fleet ONE clock. A durable timer store (`openregister_flow_timers`) +swept by a bounded cron pass, holding **elapsed-versus-suspended time** rather +than a bare target timestamp, so a deadline can be paused (Awb 4:15 +opschorting), extended once (Awb 4:14 verdaging), escalated on a threshold +ladder that fires each rung exactly once, and enforced — auto-transitioning +the subject — where the deadline has legal effect. `due_at` advises, +`expires_at` enforces, `overdue` is derived and never stored. + +This change decides **WHEN a deadline fires and WHO it escalates to**. It does +not send anything. + +## Why + +**The engine's only clock cannot survive being asked a business question.** +`WaitNode` resolves its `for`/`until` through `strtotime()` +(`lib/Service/Flow/Nodes/WaitNode.php:212-240`) and suspends the run +(`:184-196`). That is a wall-clock delay held in the run's own `resume_at`. +It cannot be cancelled when the thing it was waiting for happens early, it +cannot be paused, it knows nothing about weekends, and it pauses the RUN — so +"the applicant has eight weeks to respond" and "this branch does the next +thing in ten minutes" are the same mechanism. + +**The heartbeat is a liveness net, and says so.** `AwaitSignalNode` suspends +with a `resumeAt` a few minutes out and re-asks whether its answer arrived +(`AwaitSignalNode.php:20-26`), defaulting to 15 minutes +(`:87`) and clamped to a 5-minute floor (`:98`) because the stock system cron +cannot wake anything faster. Its own comment gives the reason: a lost signal +should cost a heartbeat rather than the flow. It is insurance against +delivery failure. It is not an SLA, it has no notion of breach, and nothing +about it escalates. + +**Only ONE app in the fleet has an enforcing deadline, and it is the one +about to retire.** openconnector's `approval_request.expiresAt` carries +`onTimeout: error|skip|dead_letter` +(`../openconnector/lib/Settings/register.d/hitl-approval-rule-action.json:90-95`) +and is swept by `ApprovalTimeoutSweepJob` at 300s +(`../openconnector/lib/Cron/ApprovalTimeoutSweepJob.php:53,71`) into +`ApprovalService::sweepExpired()` +(`../openconnector/lib/Service/ApprovalService.php:636-681`). Every other +fleet deadline merely notifies. ADR-065 retires that legacy runner, so the +enforcing semantics have to be harvested **now** or they are lost — and they +must be harvested with their two measured faults visible rather than copied: + +- The sweep is **not bounded to expired rows**. It asks for 500 `pending` + rows (`:638-647`) and filters `expiresAt` in PHP afterwards (`:655-658`). + Past 500 pending requests, an expired one outside that page is never + reached — while the method's own docblock promises "bounded to + already-expired rows only ... no full-table scan" (`:628-631`). The + instrument reports a clean sweep it did not perform. +- `onTimeout` declares three outcomes and implements two. `error` and `skip` + both fall through to `status = 'expired'` (`:662`); only `dead_letter` + branches (`:663-665`). A flow author choosing `skip` gets `error`'s + behaviour and no warning. + +**The fleet has FIVE business-day calculators and they disagree about what a +business day is.** + +| implementation | weekends | NL holidays | +|---|---|---| +| `../procest/lib/Service/Kcc/SlaCalculator.php:78,94,108` | yes | yes, computed (Easter algorithm at `:266`) | +| `../procest/lib/Service/ComplaintService.php:433-462` | yes | yes, own fixed table at `:68` | +| `../procest/lib/Service/DsoCaseService.php:470-490` | yes | yes, own fixed table at `:60-79` | +| `../procest/lib/Service/WorkQueueService.php:533-563` | yes | **no** — `$dow < 6` and nothing else | +| `../shillinq/lib/Lifecycle/SubmissionWindowGuard.php:197-204` | yes | yes, literal `Y-m-d` list | + +Two of these answer a different number for the same question. And shillinq's +holiday list is 27 literal date strings ending at `2027-12-26` +(`SubmissionWindowGuard.php:74-104`): from 2028 it silently degrades to +weekend-only, computing wrong deadlines while throwing nothing. + +**The SLA contract already exists as prose in a JSON description, with an +unsound validator.** procest's `workflowTemplate.steps[].config` is specified +at `../procest/lib/Settings/procest_register.json:3126` as +`{sla: {value: 1-10000, unit: hours|businessDays|calendarDays}, +escalationRule: {trigger: preBreach|slaBreached, offset, offsetUnit, +notifyRole, escalateToRole, openIncident}}` with the constraint "requires sla +present; preBreach offset must be <= sla.value". The units are validated +(`StepConfigValidator.php:68`, `StepConfig/EscalationRuleValidator.php:53,60`) +but the constraint is checked as a **raw integer comparison across +incompatible units** (`EscalationRuleValidator.php:176-195`: `$offset > +$sla['value']`). A 24-hour pre-breach warning on a 2-calendar-day SLA is +rejected as too long; a 5-business-day warning on a 48-hour SLA is accepted. +`offsetUnit` also omits `calendarDays`, which `sla.unit` allows — so a +calendar-day SLA has no calendar-day warning. + +**The Dutch differentiator is the part no wall-clock timer can express.** A +legal term PAUSES and resumes where it left off: the Awb 4:15 opschorting of +an omgevingsvergunning beslistermijn during a hersteltermijn stops the clock, +and the days already run stay run. procest implements this +(`../procest/lib/Service/DeadlinePauseService.php:68` registerPauze, `:132` +resumeAfterPauze — which consumes elapsed pause days and re-extends by the +**unconsumed** remainder only) and the one-shot Awb 4:14 verlenging beside it +(`../procest/lib/Service/DeadlineExtensionService.php:83,115`, ceiling checked +at `:220-229`). A store that holds only a target timestamp cannot represent +"paused with 19 days left" — on resume there is nothing to add the remainder +to. That is why this change stores elapsed and suspended time, not a moment. + +**And a threshold must fire once.** procest's ladder is +`DeadlineEscalationService::DEFAULT_MATRIX` (`:46-51`): 14 days → +`[handler]` low, 7 → `[handler, teamleader]` medium, 2 → `[+manager]` high, +0 → critical. It works because `notifyThreshold()` (`:117-149`) checks a +`notificatiesVerstuurd` ledger on the instance (`:123`) before sending. Take +the ledger away and a daily scan mails the manager every day for a fortnight. +The matrix itself is a `private const` with no configuration path, so every +other app that wants a ladder writes its own. + +## What Changes + +- **A durable timer store**, `OCA\OpenRegister\Db\FlowTimer` / + `openregister_flow_timers`, keyed by subject (`subject_type` + + `subject_uuid`, with `run_uuid`/`node_id` as optional provenance exactly as + `flow-task-entity` treats them). A subject may carry SEVERAL timers — that + is the point, see the lattice below. Timers survive restarts, fire weeks + out, and are **CANCELLED when the subject completes first**; an in-process + timer is not an option and is not offered. +- **`due_at` and `expires_at` are different columns with different + consequences.** A `due` timer NOTIFIES and nothing else. An `expiry` timer + ENFORCES: it applies an `on_expiry` outcome that transitions the subject. + The outcome vocabulary is openconnector's, harvested and completed — + `error | skip | dead_letter | transition:` — with `skip` given the + distinct behaviour its enum always promised and `:662` never gave it. +- **A bounded, index-driven sweep.** A new `FlowTimerWorker` on the existing + worker cadence (`FlowScheduleWorker.php:59` and openconnector's sweep job + both run at 300s; `FlowRunWorker.php:172` sets a 60s floor) selects on a + `(state, fire_at)` index — a range scan over DUE timers, never a page of + candidates filtered in PHP. +- **SLA `{value: 1..10000, unit: hours|businessDays|calendarDays}`** as a + first-class timer input, with the procest contract's shape preserved so a + `workflowTemplate` step config maps across without translation. +- **ONE working-calendar source**, `WorkingCalendarService`, resolving a + named calendar per organisation with a computed (not tabulated) NL national + default, and carrying `hoursPerWorkingDay` so `hours` and `businessDays` + are commensurable. The five existing implementations are not touched by + this change; they become migration targets for the per-app changes. +- **Suspend and extend as store operations**, not as recomputed timestamps: + `suspend(reason, until?)` / `resume()` moving elapsed time into a suspended + accumulator, and `extend()` bounded by `extension_max` (default 1) and + refused **after expiry** — verdaging is a decision taken while the term + still runs. +- **Escalation rules** as a typed array on the timer, + `{trigger: preBreach|slaBreached, offset, offsetUnit, notifyRole, + escalateToRole, openIncident}`, requiring `sla`, with the preBreach + constraint enforced **after normalising both sides to seconds against the + resolved calendar** — the comparison `EscalationRuleValidator.php:176-195` + gets wrong. `offsetUnit` gains `calendarDays` to match `sla.unit`. +- **A dedup ledger as rows, not as a JSON array**: `openregister_flow_timer_fires`, + unique on `(timer_uuid, rung_key)`. procest's `notificatiesVerstuurd` + (`DeadlineEscalationService.php:123`) proves the mechanism is required; + a unique index proves it under concurrent sweeps, which a read-modify-write + of a JSON column does not. +- **The procest ladder as a SEEDED, editable preset**, not a `private const`: + a `working-calendar` and an `escalation-ladder` schema with + `nl-termijn-default` = 14/7/2/0 matching `DEFAULT_MATRIX:46-51` exactly, so + the proven behaviour is the default and is still configurable. +- **The deadline LATTICE.** `legal_effect: none | servicenorm | wettelijk`. + Escalate on the servicenorm, alarm on the wettelijke termijn, and know + which expiry has legal effect: only a `wettelijk` timer may carry an + enforcing `on_expiry`, and its breach is recorded permanently rather than + cleared on completion. +- **Clock ANCHORS are stored, not just their resolved instant.** + `anchor_event` + `anchor_offset`/`anchor_offset_unit`, because the bezwaar + decision clock starts the day AFTER the objection window closes, not at + receipt — and if the anchoring event moves, the timer must re-arm + (superseding the old row) rather than keep a stale instant. +- **`overdue` is DERIVED, never stored.** No column, no state value. The + sweep does not write it and the inbox computes it. Three fleet schemas + store it today and are therefore only as correct as the last job that + remembered to write it. + +## What does NOT change + +- **`WaitNode` and `AwaitSignalNode` stay exactly as they are.** A wall-clock + in-run pause is a legitimate, different thing, and the heartbeat's liveness + job is not an SLA's job. Neither node is reimplemented on the timer store. +- **Delivery.** `flow-messaging-nodes` sends and + `flow-task-inbox-projections` projects. This change fires a named timer + transition and names the recipient roles; it owns no channel, no template + and no notification dialect. The seam is deliberate: an escalation firing + is a TRANSITION, so `x-openregister-notifications`' existing + `transition(action)` trigger carries the message declaratively (design.md + records why the WHEN cannot be declarative and the WHO-gets-told can). +- **Migration.** procest's termijnbewaking services + (`DeadlineEscalationService`, `DeadlinePauseService`, + `DeadlineExtensionService`, `DeadlineDailyScanService`, + `TermijnService`, `WOODeadlineService`), openconnector's + `ApprovalService`/`ApprovalTimeoutSweepJob`, and shillinq's + `ObligationTaskBridge` are NOT retired here. That is + `flow-approval-consolidation` and the per-app changes. This change builds + the target; nothing is pointed at it yet. +- **The five business-day calculators** keep working untouched. Introducing + a sixth that is authoritative is the point; deleting the other five is not + this change's risk to take. +- **`recurrence`.** See DEFERRED_QUESTIONS in design.md — provisionally the + TASK's, not the timer's, on the measured ground that shillinq declares + `recurrence: none|monthly|quarterly|annually` on the durable business + object (`../shillinq/lib/Settings/register.d/contract-lifecycle-management.json:617-629`) + and no PHP anywhere in shillinq reads it. + +## Capabilities + +### New Capabilities +- `flow-business-timers`: the durable timer store, its suspend/extend + arithmetic, working-calendar resolution, SLA and escalation-ladder + evaluation, the bounded sweep, and the advisory-versus-enforcing expiry + distinction. + +### Modified Capabilities + + +## Impact + +- **Affected specs**: new `flow-business-timers`. `flow-engine` untouched. +- **Affected code**: new `lib/Db/FlowTimer.php` + `FlowTimerMapper.php`, + `FlowTimerFire.php` + `FlowTimerFireMapper.php`; new + `lib/Service/Flow/Timer/FlowTimerService.php`, + `WorkingCalendarService.php`, `SlaCalculator.php`, + `EscalationLadderService.php`; new `lib/Cron/FlowTimerWorker.php`; one + migration; seed data for `working-calendar` (`nl-national`) and + `escalation-ladder` (`nl-termijn-default`). +- **Affected apps**: none yet, by design. procest, openconnector, shillinq + and decidesk become consumers in `flow-approval-consolidation`. +- **Depends on**: `flow-task-entity` — `due_at` and `expires_at` are stored + on the task there and acted on here; a timer with no subject to cancel it + when the work finishes is a mail flood, and the cancellation hook lives on + the task lifecycle. +- **Defects found while writing this proposal** (to be filed as issues, NOT + fixed inside this change): the unbounded `ApprovalService::sweepExpired()` + page (`:638-658`), `onTimeout: 'skip'` behaving as `'error'` (`:662`), the + cross-unit `preBreach` comparison + (`EscalationRuleValidator.php:176-195`), the `offsetUnit` enum missing + `calendarDays` (`:53`), and shillinq's holiday table expiring at + `2027-12-26` (`SubmissionWindowGuard.php:74-104`). +- **ADRs**: ADR-098 D1 (one engine — one clock with it), D2 (the task is a + native entity, so a timer's subject is a row, not an object); ADR-031 + (declarative-versus-imperative — design.md carries the required section); + ADR-001 (seed data); ADR-065 (openconnector's legacy runner retires, so + its enforcing semantics are harvested here first). diff --git a/openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md b/openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md new file mode 100644 index 0000000000..06d3d4b1b7 --- /dev/null +++ b/openspec/changes/flow-business-timers/specs/flow-business-timers/spec.md @@ -0,0 +1,458 @@ +## Purpose + +Durable business time for tasks and flows: deadlines that fire weeks out and +survive restarts, that pause and resume where they left off, that escalate up +a ladder without repeating themselves, and that distinguish a date which merely +advises from one which enforces and one which has legal effect. + +## ADDED Requirements + +### Requirement: A business timer is durable, subject-bound and cancelled by completion + +The system SHALL persist every business timer as a durable record, keyed by the +subject it measures (subject type and subject uuid), with the originating run +and node recorded as OPTIONAL provenance rather than as identity. A timer whose +subject has no run SHALL be first-class. + +A timer SHALL fire correctly across process restarts, deployments and arbitrary +gaps in cron execution: firing SHALL be decided from persisted state, never from +a scheduled callback, an in-memory handle, or a sleep. A timer due while the +instance was down SHALL fire on the first sweep after it comes back, and SHALL +fire exactly once. + +A subject MAY carry several timers at once. Timers are not columns on the +subject; the subject's own advisory and enforcing dates are a PROJECTION of the +timers that bear on it. + +When the subject reaches a terminal state, every non-fired timer bound to it +SHALL be cancelled with a recorded reason, in the same operation that made the +subject terminal. A cancelled timer SHALL never fire afterwards. An orphaned +timer that outlives its subject SHALL be treated as a defect, not as a +tolerable condition. + +#### Scenario: A timer set weeks out survives a restart + +- **GIVEN** an armed timer due in six weeks +- **WHEN** the instance is restarted and the sweep runs after the due moment +- **THEN** the timer MUST fire +- **AND** it MUST fire exactly once across all subsequent sweeps +- @e2e exclude covered by timer-store integration tests over a seeded clock + +#### Scenario: Completing the work cancels the deadline + +- **GIVEN** a task carrying an armed escalation timer and an armed expiry timer +- **WHEN** the task is completed before either is due +- **THEN** both timers MUST be recorded as cancelled with a reason +- **AND** no escalation MUST be raised and no expiry outcome MUST be applied +- @e2e exclude covered by cancellation-propagation integration tests + +#### Scenario: A run is not required + +- **GIVEN** a standalone subject with no run and no node +- **WHEN** a timer is armed against it +- **THEN** the timer MUST be accepted and behave identically to a run-bound one +- @e2e exclude covered by unit tests over the timer service + +### Requirement: An advisory due date notifies; an enforcing expiry transitions + +The system SHALL distinguish two timer purposes with different consequences, +and SHALL NOT collapse them into one field: + +- **`due` — advisory.** Reaching it SHALL raise the configured escalation and + SHALL NOT change the subject's state. The work stays open, assigned and + answerable after its due date passes. +- **`expiry` — enforcing.** Reaching it SHALL apply the timer's configured + outcome, which transitions the subject away from the performer. + +The enforcing outcome vocabulary SHALL be `error`, `skip`, `dead_letter` and +`transition:`, and each value SHALL produce a DISTINCT, observable +result. In particular `skip` SHALL mean "the work is abandoned and the process +continues", which is not what `error` means; an implementation in which the two +produce the same subject state SHALL be treated as not meeting this requirement. + +A subject MAY carry both a `due` timer and an `expiry` timer with different +moments. Neither SHALL be derived from the other. + +#### Scenario: Passing the due date does not close the work + +- **GIVEN** a task with a due timer and no expiry timer +- **WHEN** the due moment passes and the sweep runs +- **THEN** the escalation MUST be raised +- **AND** the task MUST remain in its current state, still assignable and still + completable by its performer +- @e2e exclude covered by sweep integration tests + +#### Scenario: skip and error are different outcomes + +- **GIVEN** two expiry timers on comparable subjects, one `onExpiry: skip` and + one `onExpiry: error` +- **WHEN** both expire +- **THEN** the resulting subject states MUST differ +- **AND** the `skip` subject's process MUST be able to continue while the + `error` subject's MUST be marked failed +- @e2e exclude covered by expiry-outcome unit tests, one case per enum value + +### Requirement: Business time is measured against ONE resolvable working calendar + +The system SHALL accept a service level expressed as `{value, unit}` where +`value` is an integer from 1 to 10000 inclusive and `unit` is one of `hours`, +`businessDays` or `calendarDays`, and SHALL reject any other shape. + +`businessDays` SHALL be resolved against a NAMED working calendar, not against a +hard-coded rule. A working calendar SHALL define which weekdays are working +days, which dates are non-working, and how many working hours a working day +contains. The working-hours figure is REQUIRED because without it `hours` and +`businessDays` are not commensurable, and the escalation constraint below +requires comparing them. + +Calendar resolution SHALL be: the calendar named on the timer, else the calendar +configured for the subject's organisation, else the seeded national default. +Resolution SHALL be deterministic and SHALL NOT silently fall back to +"weekdays only" when a named calendar cannot be resolved — an unresolvable +calendar name SHALL be an error at arm time, not a quiet downgrade at fire time. + +Non-working dates SHALL be COMPUTED for the rules that are computable rather +than enumerated as a fixed list of dates, so that the calendar does not expire. +A calendar whose correctness ends on a known date SHALL be rejected. + +#### Scenario: A business-day deadline skips non-working days + +- **GIVEN** a 3-businessDays SLA armed on a Thursday, against a calendar whose + Saturday and Sunday are non-working +- **WHEN** the fire moment is computed +- **THEN** it MUST fall on the following Tuesday, not the following Sunday +- @e2e exclude covered by working-calendar unit tests + +#### Scenario: The calendar does not expire + +- **GIVEN** the seeded national default calendar +- **WHEN** a deadline is computed for a date more than five years in the future +- **THEN** the non-working dates for that year MUST still be correct +- **AND** no year MUST resolve to weekends-only through an exhausted table +- @e2e exclude covered by calendar unit tests asserting several future years + +#### Scenario: An unknown calendar is refused, not downgraded + +- **GIVEN** a timer configuration naming a calendar that does not exist +- **WHEN** the timer is armed +- **THEN** arming MUST fail with an error naming the missing calendar +- **AND** no timer MUST be created with a substituted calendar +- @e2e exclude covered by timer-service validation tests + +### Requirement: A suspended deadline holds elapsed time, not a moment + +The system SHALL support suspending a running timer (opschorting) and resuming +it, such that the time already elapsed before suspension is PRESERVED and the +time spent suspended does NOT count against the deadline. + +The persisted state SHALL be sufficient to answer "how much of this term +remains" at any moment, including while suspended. A stored target timestamp +alone SHALL NOT be treated as sufficient: it cannot express a suspended term, +because there is no moment to which the remainder can be added until the term +resumes. + +Remaining time SHALL be preserved in the timer's OWN unit. A term expressed in +business days that is suspended over a weekend SHALL resume with the same number +of business days remaining — the weekend consumed neither elapsed time nor +suspended time that mattered. + +While suspended, a timer SHALL NOT fire, SHALL NOT escalate, and SHALL NOT be +reported as overdue. Suspending and resuming SHALL each be recorded with actor, +moment and reason, because the suspension of a legal term is itself a decision +that has to be evidenced. + +#### Scenario: A hersteltermijn pause returns the remainder intact + +- **GIVEN** an 8-week term with 19 days elapsed, suspended for a 14-day + hersteltermijn +- **WHEN** the applicant responds on day 6 of the suspension and the term + resumes +- **THEN** the remaining term MUST be the original 8 weeks minus 19 days +- **AND** the 6 suspended days MUST NOT have been consumed +- @e2e exclude covered by suspension arithmetic unit tests + +#### Scenario: A suspended term is not overdue + +- **GIVEN** a timer suspended before its fire moment, left suspended past that + moment +- **WHEN** the sweep runs and the subject is listed +- **THEN** the timer MUST NOT fire +- **AND** the subject MUST NOT be reported as overdue +- @e2e exclude covered by sweep and derivation unit tests + +#### Scenario: Suspension is evidenced + +- **GIVEN** a timer suspended and later resumed +- **WHEN** its history is read +- **THEN** both events MUST carry the acting identity, the moment and the + recorded reason +- @e2e exclude covered by timer-history integration tests + +### Requirement: An extension is bounded and may only be granted before expiry + +The system SHALL support extending a timer (verdaging) by a stated amount with a +stated rationale, and SHALL bound how many times a given timer may be extended. +The default bound SHALL be ONE. + +An extension SHALL be REFUSED once the timer has fired or expired. A term that +has already run out cannot be lengthened retroactively; permitting it would let +a breach be erased after the fact. + +An extension SHALL require a non-empty rationale and SHALL record the actor, +the moment, the prior fire moment and the new one. An extension request that +would exceed the bound SHALL be refused with an error naming the bound; an +authorized override path MAY exist but SHALL be a distinct, separately +authorized operation that is itself recorded as an override. + +#### Scenario: The second extension is refused + +- **GIVEN** a timer with an extension bound of one that has been extended once +- **WHEN** a second extension is requested through the standard path +- **THEN** it MUST be refused with an error naming the bound +- **AND** the fire moment MUST be unchanged +- @e2e exclude covered by extension unit tests + +#### Scenario: Extending after expiry is refused + +- **GIVEN** a timer that has already fired +- **WHEN** an extension is requested +- **THEN** it MUST be refused +- **AND** the recorded breach MUST remain recorded +- @e2e exclude covered by extension unit tests + +### Requirement: Each escalation rung fires exactly once + +The system SHALL support an ordered escalation ladder against a timer, where +each rung names a distance from the deadline and the recipients, priority and +message identity for that distance. Reaching a rung SHALL raise a NAMED +transition on the timer carrying the rung's recipients and priority. + +Each rung SHALL fire AT MOST ONCE per timer. This SHALL be enforced by a +uniqueness constraint on the persisted fire record, not by a read-then-write +check on a document, so that two concurrent sweeps cannot both conclude the rung +is unfired. + +A sweep pass that skips several rungs — because the instance was down, or +because an extension moved the deadline backwards — SHALL fire the rungs it +passed in ladder order, each once, and SHALL NOT collapse them into the most +severe one only. + +The seeded default ladder SHALL be 14 days to the handler at low priority, +7 days to the handler and team leader at medium, 2 days to the handler, team +leader and manager at high, and 0 days to the same recipients at critical. The +ladder SHALL be data that an administrator can edit, not a compiled-in constant. + +#### Scenario: A daily sweep does not repeat a rung + +- **GIVEN** a timer whose 7-day rung fired yesterday +- **WHEN** the sweep runs again today, still more than 2 days from the deadline +- **THEN** no escalation MUST be raised +- @e2e exclude covered by ladder dedup integration tests + +#### Scenario: Concurrent sweeps fire a rung once + +- **GIVEN** two sweep passes evaluating the same unfired rung at the same moment +- **WHEN** both attempt to fire it +- **THEN** exactly one MUST succeed +- **AND** the other MUST observe the rung as already fired and take no action +- @e2e exclude covered by a concurrency test asserting the uniqueness constraint + +#### Scenario: A downtime gap fires the skipped rungs in order + +- **GIVEN** a timer whose 14-day and 7-day rungs are both unfired, and the + deadline is now 5 days away +- **WHEN** the sweep runs +- **THEN** both rungs MUST fire, in ladder order, once each +- @e2e exclude covered by sweep integration tests over a seeded clock + +### Requirement: An escalation rule is validated against its SLA in commensurable units + +An escalation rule SHALL take the shape `{trigger, offset, offsetUnit, +notifyRole, escalateToRole, openIncident}` where `trigger` is `preBreach` or +`slaBreached` and `offsetUnit` is one of `hours`, `businessDays` or +`calendarDays` — the SAME unit set the SLA accepts, so that any SLA can carry a +warning expressed in its own terms. + +An escalation rule SHALL be REFUSED unless an SLA is present on the same +configuration: a warning before a breach is meaningless without the term it +warns about. + +For `trigger: preBreach`, the offset SHALL NOT exceed the SLA. This comparison +SHALL be made after NORMALISING both the offset and the SLA to an absolute +duration against the resolved working calendar. Comparing the two `value` +integers directly SHALL be treated as not meeting this requirement, because it +both rejects valid configurations and admits invalid ones whenever the units +differ. + +#### Scenario: A short warning on a longer SLA is accepted across units + +- **GIVEN** an SLA of `{value: 2, unit: calendarDays}` and a preBreach rule of + `{offset: 24, offsetUnit: hours}` +- **WHEN** the configuration is validated +- **THEN** it MUST be accepted, because 24 hours is inside 2 calendar days +- @e2e exclude covered by escalation-rule validator unit tests + +#### Scenario: A long warning on a shorter SLA is refused across units + +- **GIVEN** an SLA of `{value: 48, unit: hours}` and a preBreach rule of + `{offset: 5, offsetUnit: businessDays}` +- **WHEN** the configuration is validated +- **THEN** it MUST be refused with an error stating the offset exceeds the SLA +- @e2e exclude covered by escalation-rule validator unit tests + +#### Scenario: An escalation rule without an SLA is refused + +- **GIVEN** a configuration carrying an escalation rule and no SLA +- **WHEN** it is validated +- **THEN** it MUST be refused +- @e2e exclude covered by escalation-rule validator unit tests + +### Requirement: Overdue is derived from the clock and never stored + +The system SHALL derive overdue-ness by comparing the applicable deadline to the +current moment at read time. It SHALL NOT persist an `overdue` flag, an +`overdue` state value, or any equivalent field whose truth depends on the clock. + +Derived time facts — whether a subject is past due, how long until it is due, +how long it has been overdue — SHALL be computed on read and SHALL account for +suspension: suspended time SHALL NOT contribute to being overdue. + +A query for overdue subjects SHALL return the correct set whether or not any +background job has run. Correctness SHALL NOT depend on a sweep having +previously written a marker. + +#### Scenario: Overdue is correct with the sweep disabled + +- **GIVEN** a subject whose deadline passed while the sweep job was disabled +- **WHEN** the overdue query runs +- **THEN** the subject MUST be returned as overdue +- @e2e exclude covered by derivation unit tests with no job execution + +#### Scenario: No writable overdue field is exposed + +- **GIVEN** the timer and subject write surfaces +- **WHEN** a caller attempts to set overdue-ness directly +- **THEN** there MUST be no field accepting it +- @e2e exclude covered by API contract tests over the write surface + +### Requirement: A deadline declares its legal effect, and only a legal one enforces + +The system SHALL record, per timer, which kind of deadline it is: + +- `none` — an internal or planned date; +- `servicenorm` — a service standard the organisation set itself; +- `wettelijk` — a term with legal effect. + +These SHALL be able to coexist on one subject with DIFFERENT moments, because +they routinely do: the service standard is escalated on, the legal term is +alarmed on, and the planned date is neither. + +An enforcing outcome — one that transitions the subject — SHALL be permitted +ONLY on a timer whose legal effect is `wettelijk`. A `servicenorm` or `none` +timer SHALL be advisory regardless of configuration, and an attempt to give one +an enforcing outcome SHALL be refused at arm time. + +The breach of a `wettelijk` timer SHALL be recorded permanently and SHALL +survive the subject's completion, because the fact that a statutory term was +exceeded does not stop being true when the work is eventually done. + +#### Scenario: Three deadlines on one subject + +- **GIVEN** a subject with a planned date, a service standard and a statutory + term at three different moments +- **WHEN** each is reached in turn +- **THEN** each MUST produce its own outcome independently +- **AND** none MUST be overwritten or superseded by another +- @e2e exclude covered by lattice integration tests + +#### Scenario: A service standard cannot enforce + +- **GIVEN** a timer with legal effect `servicenorm` configured with an enforcing + outcome +- **WHEN** it is armed +- **THEN** arming MUST be refused with an error naming the legal-effect + constraint +- @e2e exclude covered by timer-service validation tests + +#### Scenario: A statutory breach outlives completion + +- **GIVEN** a `wettelijk` timer that fired as breached +- **WHEN** the subject is later completed +- **THEN** the breach record MUST remain readable +- @e2e exclude covered by timer-history integration tests + +### Requirement: A deadline's anchor is stored, so a moved anchor re-arms the timer + +The system SHALL store a timer's clock ANCHOR — the named event the term runs +from, plus any offset from it — alongside the computed fire moment, and SHALL +NOT store only the computed moment. + +The anchor is stored because a term frequently does not start when the subject +was created. A decision term on an objection runs from the day AFTER the +objection window closes, not from the day the objection was received; storing +only "fires on the 15th" loses the fact that the 15th was derived from a window +that can itself move. + +When the anchoring event moves, the system SHALL re-arm the timer from the new +anchor: the prior timer SHALL be marked superseded rather than mutated, and a +new timer SHALL carry the recomputed moment, so the history shows what the +deadline used to be and why it changed. Escalation rungs already fired on the +superseded timer SHALL NOT be re-fired by the successor unless the successor's +deadline puts them back in the future. + +#### Scenario: The clock starts after the window closes + +- **GIVEN** an objection received on the 3rd, an objection window closing on the + 20th, and a decision term anchored to `window_closed` with an offset of one + calendar day +- **WHEN** the timer is armed +- **THEN** the term MUST start on the 21st, not the 3rd +- @e2e exclude covered by anchor-resolution unit tests + +#### Scenario: A moved window supersedes the timer + +- **GIVEN** an armed timer anchored to a window that is subsequently extended +- **WHEN** the anchoring event moves +- **THEN** the prior timer MUST be marked superseded and a successor MUST carry + the recomputed moment +- **AND** the superseded timer MUST NOT fire +- @e2e exclude covered by re-arm integration tests + +### Requirement: The sweep is bounded to due work by index, not by a page of candidates + +The sweep SHALL select the work it processes by a query bounded on state AND +fire moment together, so that every row it reads is a row it acts on. + +It SHALL NOT select a fixed page of open records and then discard the not-yet-due +ones in application code. That shape has a measurable failure mode: once the +number of open records exceeds the page size, a due record outside the page is +never reached, and the job reports a clean pass while doing nothing about it. + +Each pass SHALL be bounded by a batch limit and SHALL be safely re-entrant: two +overlapping passes SHALL NOT double-fire, and an interrupted pass SHALL leave no +timer half-fired. A pass SHALL log the counts it acted on, and those counts +SHALL reflect work performed rather than rows examined. + +#### Scenario: A due timer beyond the batch size is still reached + +- **GIVEN** a batch limit of N, more than N armed timers that are not yet due, + and one armed timer that is due +- **WHEN** the sweep runs +- **THEN** the due timer MUST be processed in that pass +- @e2e exclude covered by sweep integration tests seeding beyond the batch limit + +#### Scenario: Overlapping passes do not double-fire + +- **GIVEN** two sweep passes overlapping over the same due timer +- **WHEN** both run to completion +- **THEN** the timer MUST fire exactly once +- **AND** its outcome MUST be applied exactly once +- @e2e exclude covered by a concurrency test over the sweep + +#### Scenario: Counts report work, not reads + +- **GIVEN** a sweep pass over a store containing many not-due timers and three + due ones +- **WHEN** the pass logs its result +- **THEN** the reported fired count MUST be three +- @e2e exclude covered by sweep logging unit tests diff --git a/openspec/changes/flow-business-timers/tasks.md b/openspec/changes/flow-business-timers/tasks.md new file mode 100644 index 0000000000..ac4de9c50a --- /dev/null +++ b/openspec/changes/flow-business-timers/tasks.md @@ -0,0 +1,217 @@ +# Tasks: flow-business-timers + +## 1. Storage + +- [ ] 1.1 Migration creating `openregister_flow_timers`, + `openregister_flow_timer_fires` and `openregister_flow_timer_events` + with the columns in design.md — Data model. Indexes + `or_flowtimer_due_idx (state, fire_at)`, + `or_flowtimer_rung_idx (state, next_rung_at)`, + `or_flowtimer_subj_idx (subject_type, subject_uuid, state)`, + `or_flowtimer_run_idx (run_uuid)`, unique `or_flowtimer_uuid_idx (uuid)`, + unique `or_flowtimfire_uq (timer_uuid, rung_key)`, + `or_flowtimev_timer_idx (timer_uuid, created)`. All names ≤ 30 chars, + matching `or_flowtrig_match_idx` + (`lib/Migration/Version1Date20260810140000.php:98`). Additive only. + Verify NO `overdue`, `is_overdue` or `days_overdue` column exists. +- [ ] 1.2 `lib/Db/FlowTimer.php` + `FlowTimerMapper`, `FlowTimerFire.php` + + `FlowTimerFireMapper`, `FlowTimerEvent.php` + `FlowTimerEventMapper`. + Mapper finders: due-by-`fire_at`, due-by-`next_rung_at`, by subject, + by run, by lineage. The fire and event mappers expose insert and read + only — no update, no delete path. +- [ ] 1.3 Seed descriptor under `lib/Settings/` + a `lib/Repair/SeedFlowTimerRegister` + step registered in `appinfo/info.xml` beside the existing `Seed*` steps + (`:159-170`), decoding the JSON and calling + `ConfigurationService::importFromApp(force: false)` — NOT + `importFromFilePath()`, per `lib/Repair/ImportCredentialBrokerRegister.php:104-118`. + Ships `working-calendar` `nl-national` + one organisation override and + `escalation-ladder` `nl-termijn-default` (14/7/2/0, matching + `../procest/lib/Service/DeadlineEscalationService.php:46-51` value for + value). Re-running the step changes nothing. + +## 2. Working calendar and SLA arithmetic + +- [ ] 2.1 `lib/Service/Flow/Timer/WorkingCalendarService.php` — resolution + order timer → organisation → seeded default, throwing at arm time on an + unknown name with the name in the message, and NO weekday-only fallback + on any path. Rule kinds `fixed`, `easter` (computed, as + `../procest/lib/Service/Kcc/SlaCalculator.php:266` does) and + `observedShift`. Validation REFUSES a calendar that is only enumerated + dates — the failure mode live at + `../shillinq/lib/Lifecycle/SubmissionWindowGuard.php:74-104`, which ends + at `2027-12-26` and then degrades silently. `hoursPerWorkingDay` + required. +- [ ] 2.2 `lib/Service/Flow/Timer/SlaCalculator.php` — `measure(from, to, unit)`, + `add(from, value, unit)`, `sub(from, value, unit)` over a resolved + calendar for `hours`, `businessDays` and `calendarDays`; `{value, unit}` + accepted only for integer `value` 1..10000. Non-working dates memoised + per (calendar, year) for the life of a sweep pass. + +## 3. Timer lifecycle + +- [ ] 3.1 `FlowTimerService::arm()` — resolves `anchor_event` + + `anchor_offset` to `anchor_at`, stores the anchor alongside the computed + moment, validates the SLA and escalation rules, and REFUSES an + `on_expiry` outcome on any timer whose `legal_effect` is not + `wettelijk`. One private `recompute()` derives `fire_at` and + `next_rung_at`; every mutating operation calls it and nothing else + writes those two columns. +- [ ] 3.2 `suspend(reason, until?)` / `resume()` — `consumed_value += + calendar.measure(running_since, now, budget_unit)`, `running_since` and + `fire_at` to NULL, `suspended_since` set; resume re-projects from the + resume instant. Both write an event row with actor, moment, reason and + `basis`. A suspended timer neither fires nor escalates nor reports + overdue. Do NOT copy procest's pre-extension model + (`../procest/lib/Service/DeadlinePauseService.php:145-153`) — its + `min($durationDays, $diff)` at `:148` silently eats an over-run pause. +- [ ] 3.3 `extend(amount, unit, rationale)` — increases `budget_value`, + requires a non-empty rationale, bounded by `extension_max` (default 1) + with an error naming the bound, and REFUSED once `state` is `fired`, + `cancelled` or `superseded`. The override is a separate, separately + authorized method recorded as an override, mirroring + `../procest/lib/Service/DeadlineExtensionService.php:126,228` — not a + flag on `extend()`. +- [ ] 3.4 `supersede()` on a moved anchor — the prior row goes to + `superseded` and never fires; a successor carries `supersedes_uuid`, + the recomputed `anchor_at`/`fire_at` and the predecessor's + `consumed_value`, and inherits a fire row (marked `inherited`) for every + rung still in the past under the new deadline and none for the rungs + pushed back into the future. + +## 4. Escalation + +- [ ] 4.1 `lib/Service/Flow/Timer/EscalationLadderService.php` — resolves the + ladder, computes each rung's instant, and CLAIMS a rung by inserting + `(timer_uuid, rung_key)` BEFORE raising the transition; a duplicate key + means another pass owns it. The fire row records the transition raised + and its roles/priority, never "notified". A gap fires every passed + unfired rung in ladder order, each once, and never collapses them into + the most severe. +- [ ] 4.2 Escalation-rule validation — shape `{trigger, offset, offsetUnit, + notifyRole, escalateToRole, openIncident}`, `offsetUnit` accepting + `calendarDays` as well (`../procest/lib/Service/StepConfig/EscalationRuleValidator.php:53` + is `['hours', 'businessDays']` today), refused without an SLA, and + `preBreach` validated as + `calendar.sub(fire_at, offset, offsetUnit) >= anchor_at` — instants, not + the raw integer comparison at `EscalationRuleValidator.php:176-195`. + Config-time validation is advisory against a probe anchor; arm-time is + authoritative and names the anchor in its refusal. + +## 5. Sweep and outcomes + +- [ ] 5.1 `lib/Cron/FlowTimerWorker.php` extending `TimedJob` at + `setInterval(seconds: 300)`, matching `lib/Cron/FlowScheduleWorker.php:59`. + Two bounded range scans — `(state, fire_at)` for expiries and + `(state, next_rung_at)` for rungs — each `LIMIT` batch (default 200), + never a page of open rows filtered in PHP as + `../openconnector/lib/Service/ApprovalService.php:638-658` does under a + docblock claiming the opposite (`:628-631`). Logged counts report work + performed; a pass hitting the limit logs `truncated: true`. +- [ ] 5.2 Expiry outcomes applied as NAMED TASK ACTIONS through the task + service, claimed by a conditional `SET state='fired' WHERE uuid=? AND + state='armed'` so zero affected rows means another pass owns it. All + four of `skip`, `error`, `dead_letter`, `transition:` produce + DISTINCT observable subject states — `skip` continues the process, + `error` fails it; the collapse at `ApprovalService.php:662` is the + defect being corrected, not the behaviour being copied. A `wettelijk` + breach is recorded permanently and survives completion. +- [ ] 5.3 Cancellation inside the transaction that makes the subject terminal, + extending `flow-task-entity`'s run-terminality listener — idempotent, + recording `cancel_reason` and `cancelled_at`, never deleting. Plus a + repair check that COUNTS armed timers whose subject is terminal or + absent and reports them as defects rather than quietly cancelling them. + +## 6. Projection and derivation + +- [ ] 6.1 `openregister_tasks.due_at` / `expires_at` maintained as a + projection inside every timer mutation — earliest non-cancelled `due` + timer and earliest enforcing timer — so the inbox index + `(assignee, is_terminal, due_at)` stays an index hit. The task write + surface REFUSES those fields once a timer owns the subject; writing them + never creates a timer. `suspended_until` is display-only. +- [ ] 6.2 Derived read API — overdue, time-remaining and time-overdue computed + on read as `state = 'armed' AND fire_at < now`, correct with the sweep + disabled, with no field anywhere accepting an overdue write. + `remaining = budget_value - consumed_value - (running_since ? + calendar.measure(running_since, now, budget_unit) : 0)`, answerable + while suspended. + +## 7. Tests and verification + +- [ ] 7.1 Arithmetic and calendar unit tests: the 8-week / 19-days-elapsed / + 6-of-14-day-hersteltermijn case returning the remainder intact; a + business-day term suspended over a weekend resuming with the same + business days left; a 3-businessDays SLA armed on a Thursday landing on + Tuesday; `nl-national` correct for several future years including a + Koningsdag falling on a Sunday; both cross-unit `preBreach` scenarios; + one case per `on_expiry` value asserting the four states differ. +- [ ] 7.2 Sweep, concurrency and invariant tests: a due timer beyond the batch + size still processed; two overlapping passes firing a rung and an expiry + exactly once each; a downtime gap firing the skipped rungs in order; a + six-week timer surviving a restart; completion cancelling both timers + with no escalation raised; and an invariant test asserting + `fire_at = calendar.add(running_since, budget - consumed)` and the task + projection after EVERY operation in the state machine. +- [ ] 7.3 Regression pass with opencatalogi and softwarecatalog installed: + flows still queue, advance and complete; `WaitNode` and + `AwaitSignalNode` behave identically; the migration applied twice yields + identical schema and seed state; and `openregister_flow_triggers` and + its `or_flowtrig_match_idx` are untouched. + +## Acceptance criteria + +- Firing is decided from persisted state alone. A grep for `sleep`, a + scheduled callback or an in-memory handle in the timer code returns + nothing, and the restart test passes with the job disabled between arm and + due. +- No column, enum value or writable field anywhere records overdue-ness, and + the overdue query returns the correct set with the sweep job disabled. +- `skip`, `error`, `dead_letter` and `transition:` leave the subject + in four distinguishable states. A test asserts all four, one per value. +- A rung fires at most once per timer, decided by the unique index rather + than by a read-then-write, and proven by a concurrency test rather than by + reading the code. +- `businessDays` is never computed without a resolved named calendar. An + unknown calendar name fails at arm time; no code path substitutes weekdays. +- `preBreach` validation gives the right verdict on both spec scenarios, + which the raw-integer comparison it replaces gets wrong in both directions. +- An enforcing `on_expiry` exists only on a `wettelijk` timer, and a + `servicenorm` timer configured with one is refused at arm time. +- The sweep reads only rows it acts on. Seeding beyond the batch limit with + one due timer still processes that timer in the same pass. +- Every armed timer satisfies the `fire_at` identity, every suspended timer + has NULL `fire_at` and NULL `running_since`, and no armed timer's subject + is terminal. +- No app is pointed at the store by this change: procest, openconnector and + shillinq keep their own deadline services, and no data is copied. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- New PHP files carry `@license EUPL-1.2` and `@copyright 2026 Conduction B.V.` +- `@spec` annotations point at + `openspec/specs/flow-business-timers/spec.md` anchors. +- References ADR-098 D1 (one engine, one clock) and D2 (the subject is a + row), ADR-031 (the imperative WHEN is argued in design.md D-1 against the + measured limits of the scheduled-filter grammar, not assumed), ADR-001 + (the calendar and ladder are seeded data, not a `private const`), ADR-065 + (openconnector's enforcing semantics harvested before its runner retires). +- `WaitNode` and `AwaitSignalNode` are NOT modified, and no node type is + added to the engine. +- This change sends nothing: no channel, no template, no recipient uid + resolution, no notification dialect. Grep the new code for a notification + emit and expect zero hits. +- `FlowTimerService` contains no branch on a specific app's subject type. + Every branch is time arithmetic, calendar resolution, state or concurrency. +- Five defects found while writing this change are FILED AS ISSUES and not + fixed here: the unbounded `ApprovalService::sweepExpired()` page + (`:638-658`); `onTimeout: 'skip'` behaving as `'error'` (`:662`); the + cross-unit `preBreach` comparison (`EscalationRuleValidator.php:176-195`); + the `offsetUnit` enum missing `calendarDays` (`:53`); and shillinq's + holiday table expiring at `2027-12-26` + (`SubmissionWindowGuard.php:74-104`). Add the sixth found during design: + shillinq's `ContractObligation.obligationDeadline` scheduled notification + uses a `{all: [...]}` filter grammar with `notIn`/`before` operators that + `ScheduledFilterEvaluator` does not implement, so it matches nothing and + has never fired + (`../shillinq/lib/Settings/register.d/contract-lifecycle-management.json:701-720`). diff --git a/openspec/changes/flow-cmmn-case-semantics/.openspec.yaml b/openspec/changes/flow-cmmn-case-semantics/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/flow-cmmn-case-semantics/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/flow-cmmn-case-semantics/design.md b/openspec/changes/flow-cmmn-case-semantics/design.md new file mode 100644 index 0000000000..14959b995f --- /dev/null +++ b/openspec/changes/flow-cmmn-case-semantics/design.md @@ -0,0 +1,512 @@ +# Design: flow-cmmn-case-semantics + +## Context + +See proposal.md — Why. The design-relevant state of the code today: + +**In OpenRegister:** + +- A flow is a Petri net. `FlowGraph::inPlace()` + (`lib/Service/Flow/FlowGraph.php:67`) makes a node's input place its own + node id and `joinPlace()` (`:103`) is `"{nodeId}#{edgeId}"`, so a run's + persisted `marking` is a set of identifiers drawn from the graph. The + engine hops with a hard ceiling, `MAX_TRANSITIONS = 1000` + (`lib/Service/Flow/FlowEngine.php:103`, enforced at `:325`). +- `FlowRunService::queue()` (`lib/Service/Flow/FlowRunService.php:321`) is + the single funnel every dispatch path passes through, and — after + `flow-definition-versioning` — the place a run's definition version is + pinned. +- A run already knows its subject: `subject_uuid`, `subject_register`, + `subject_schema` (`lib/Db/FlowRun.php:194-208`). +- Conditions are JSONLogic. `FlowExpression::isTrue()` + (`lib/Service/Flow/FlowExpression.php:140-160`) evaluates against the + document built by `dataFor()` (`:82-97`: `json`, `binary`, `itemIndex`, + `itemCount`, `context`, `subject`), and returns **false** when the + expression could not be evaluated — already the fail-closed behaviour a + sentry needs. +- Trigger events are a closed catalog: `EventCatalogService::CATALOG` + (`lib/Service/Flow/EventCatalogService.php:52-69`), 17 entries including + `object.transitioned` (`:59`). `knownTriggerIds()` (`:85`) is what a flow + may legally store. +- **`FlowState` is per-FLOW, not per-subject.** Its table has a UNIQUE index + on `flow_id` alone (`lib/Migration/Version1Date20260731080000.php:104`) and + its mapper is `findByFlow(string $flowId)` + (`lib/Db/FlowStateMapper.php:65`). It is the wrong place to hang per-case + state, and this design does not use it. +- The declarative lifecycle dialect operates on register objects: + `x-openregister-lifecycle` executed by + `lib/Service/Lifecycle/TransitionEngine.php`, with the transition `inputs` + allowlist at `:674-704`. + +**In procest — the reference implementation (2,004 lines, nine classes):** + +- `PlanItemTransitions.php` — six states (`:41-46`), three types (`:48-50`), + an exhaustive per-type edge table (`:59-84`) and an explicit terminal set + (`:93-97`). `assertLegal()` (`:153-162`) throws + `IllegalPlanItemTransitionException` naming item, type, from and to. +- `SentryEvaluator.php` — AND within a sentry, OR across the array + (`:65-104`); on-part against current plan-item state because + complete/terminate/disable are monotonic (`:107-144`, and the docblock at + `:18-23` argues the point); if-part over its OWN + `{field, operator, value}` dialect (`:154-196`). +- `PlanItemTree::stageMandatoryChildrenAllTerminal()` (`:98-117`) — returns + `$mandatoryFound`, so a stage with only discretionary children returns + false. The docblock at `:88-90` says why: "all terminal" would trivially + auto-complete such a stage on activation. +- `PlanItemCascade` — `MAX_CASCADE_DEPTH = 50` (`:64`), fixpoint passes + (`:94-123`). +- `CaseModelEngine` — the public surface: `getCasePlan()` (`:92`), + `enableDiscretionaryItem()` (`:118`), `completeTask()` (`:168`), + `terminateTask()` (`:185`), `signalCaseFileEvent()` (`:203`), + `getPlanItemAuthorization()` (`:244`), `getEnableableDiscretionaryItems()` + (`:269`). +- `CasePlanRepository::persist()` (`:151-152`) is one line: + `$ctx['case']['casePlanState'] = json_encode($ctx['state']);`. The field it + writes is declared `"type": "string"`, `"visible": false` + (`procest/lib/Settings/procest_register.json:1188-1192`). +- The definition shape is already close to what we want: + `procest/lib/Settings/register.d/70-cmmn-case-model.json:50` declares the + type enum, and `:56-84` the `entryCriteria` sentry shape. +- The ZTC surface exists: `procest/lib/Controller/ZtcController.php:982-989` + resolves `eigenschappen`, `statustypen`, `resultaattypen` and `roltypen` + per zaaktype. + +## Goals / Non-Goals + +**Goals:** + +- Express a case whose next step is a judgement, not an arrow, without + changing anything about how the Petri net executes. +- Make plan state queryable: "which cases are stuck where" is an indexed + query, not a decode-every-row scan. +- Reuse the engine's existing condition language and event catalog for + sentries, so a case author and a flow author learn one dialect. +- Let a caseworker attach work at runtime without touching a pinned, + immutable published flow version. +- Give the zaaktype — the artifact a Dutch buyer actually has — a first-class + import path. + +**Non-Goals:** + +- **Compiling the case plan into the Petri net.** Considered and rejected in + D-5; the reason is structural, not preferential. +- A case UI. The API and the model land here; the canvas and the case view + are nc-vue work behind `CnGraphCanvas` (ADR-098 D8). +- Per-item concurrency beyond row-level. Two transitions on the SAME plan + item serialise; two on different items do not. +- Case migration between definition versions, for the same reason + `flow-definition-versioning` refused in-flight instance migration. +- Any interpretation of `doorlooptijd`/`servicenorm` beyond storing them. + +## Decisions + +### D-1 — Declarative-vs-imperative decision (ADR-031) + +ADR-031's default is declarative: business logic belongs in +`x-openregister-*` on a schema, executed by the shared engines, not in a new +Service class. This change lands on BOTH sides of that line, deliberately. + +**Imperative: the plan-item machine.** `x-openregister-lifecycle` transitions +a register OBJECT. A plan item is not a register object — it is a row in a +native table, for the reasons in D-2 — so `TransitionEngine` has nothing to +transition, `x-openregister-lifecycle` has no schema to hang on, and the +`inputs` allowlist (`lib/Service/Lifecycle/TransitionEngine.php:674-704`) has +no object write to validate against. More importantly the dialect cannot +express what a sentry needs: a sentry is a condition over ANOTHER item's +state plus the anchoring object's state, and the declarative lifecycle has no +cross-record referential form. This is the same argument +`flow-definition-versioning`'s design made for its lifecycle guard, and the +same one procest reached for `workflowTemplate` even though that IS an +object. + +**Declarative: the write-through.** The business status the plan advances — +the zaak's `status`, its `resultaat` — is a property of a register object and +is written through the ordinary object-write path, so +`x-openregister-lifecycle` on the case schema governs it, `object.transitioned` +is emitted by the existing machinery, readOnly enforcement applies, and every +existing notification rule keyed on `transition(action)` keeps working +untouched. The case layer supplies the transition; it does not reimplement +one. + +**Declarative: the sentry if-part.** JSONLogic via `FlowExpression`, not a new +evaluator — see D-4. + +**Notifications, aggregations, derived fields, relations, widgets:** none are +introduced by this change. A plan item's realisation notifies through the +task capability's existing path; nothing here dispatches directly. + +### D-2 — Rows, not a JSON blob + +The reference implementation keeps the whole runtime plan in one string field +(`CasePlanRepository.php:151-152` writing +`procest_register.json:1188`'s `casePlanState`). Three costs, each of which +this change is partly for: + +1. **Nothing is queryable.** "Which bezwaren are waiting on external advice?" + requires loading and decoding every case object. There is no index on a + field inside a JSON string. +2. **Every transition is a whole-blob read-modify-write.** Two caseworkers + completing two different items in the same case is a lost-update race by + construction. Row-level storage makes them independent. +3. **`"visible": false`** means the state driving the case is invisible in + every generic OR surface — the object detail page, exports, the audit + trail's diff. A field nobody can see is a field nobody can verify. + +Alternative considered: keep a blob but add a projection table for querying. +Rejected — two representations of one truth, and the projection is exactly +the kind of derived-and-stored field the fleet has already been burned by +(`overdue`, three schemas, decidiq#846). + +### D-3 — The case anchor is the OpenRegister object; no case entity + +A plan item carries `object_uuid` + `register_id` + `schema_id` — the same +triple `FlowRun` already carries as `subject_*` (`lib/Db/FlowRun.php:194-208`) +and the same triple `flow-task-entity` anchors a task on. + +Alternatives: + +- **A `Case` entity with its own id and lifecycle.** Rejected: it duplicates + what the object already is, and it creates a second identity for one thing + — the classic consequence being two statuses that disagree. Camunda needs + this (a process instance has no domain object, so Camunda 7 bolts on a + "document"/business-key concept and Valtimo built a whole document module + around it); we do not, because in OR the zaak, the bezwaar and the + vergunning already ARE objects with uuids, schemas, audit and + authorization. +- **`FlowState`.** Rejected on fact: it is keyed by flow uuid, UNIQUE on + `flow_id` (`lib/Migration/Version1Date20260731080000.php:104`, + `lib/Db/FlowStateMapper.php:65`), and holds state that outlives runs of ONE + flow. It is per-flow bookkeeping, not per-subject state, and using it for a + case would collide every case of the same type into one row. + +Consequence worth stating: a case plan is resolvable for an object that never +had a run. That is not an edge case, it is the ad-hoc path (D-6). + +### D-4 — Sentries reuse `FlowExpression` and the event catalog + +The reference `SentryEvaluator` carries its own operator vocabulary — +`eq|neq|gt|gte|lt|lte|in|notIn|truthy|falsy` at +`procest/lib/Service/Cmmn/SentryEvaluator.php:178-196`, with deliberately +loose `==` comparison at `:188-189`. Adopting it would make **four** +condition dialects in the fleet: JSONLogic in the flow engine, +`ScheduledFilterEvaluator`'s `equals|notEquals|withinNext|olderThan`, the +three invented notification dialects that are all silently dead +(openregister#2787, 24 rules), and this. The cost of a fourth is not +theoretical — the 24 dead rules are what a second dialect looks like a year +later. + +So: + +- **if-part** = a JSONLogic expression through + `FlowExpression::isTrue()`. The evaluation document extends `dataFor()`'s + shape with a `case` key carrying `{items: {itemId: state}, object: {...}}` + — additive, so an author who knows flow expressions already knows sentry + expressions. `isTrue()` returning false for an unevaluable expression + (`FlowExpression.php:145-149`) is precisely the fail-closed semantics + `SentryEvaluator::ifPartSatisfied()` implements by hand at `:157-160`; we + get it from the shared primitive instead. +- **on-part** = an event id from `EventCatalogService::CATALOG`, extended + with `case.item.completed`, `case.item.terminated`, `case.item.disabled`. + Validation at save time is `knownTriggerIds()` + (`EventCatalogService.php:85`), so an author's typo fails in the editor + rather than producing a sentry that never fires. + +**Monotonicity is kept, and it is why no event log is needed for plan-item +on-parts.** `completed`/`terminated`/`disabled` are terminal +(`PlanItemTransitions.php:93-97`), so "the event has occurred" and "the item +is currently in that state" are the same question — the argument at +`SentryEvaluator.php:18-23`, and it survives the port unchanged. Object-state +on-parts are NOT monotonic, which is why they go through the event catalog +and are evaluated against the event being handled, not against a stored +history. + +### D-5 — The case layer schedules; the Petri net executes + +The tempting design is to compile the case plan into a flow document — +plan items become nodes, sentries become edge conditions — so that everything +is one Petri net. It does not survive contact with two facts: + +1. **A run's marking names graph identifiers** + (`FlowGraph::inPlace()`, `:67`) and `flow-definition-versioning` pins a run + to an immutable published version. A discretionary item enabled on day + three, or an ad-hoc item that appears in no definition at all, would have + to ADD a node to a graph a live run is pinned to. That is precisely the + dangling-marking failure versioning exists to prevent. +2. **Sentries are not edges.** An entry criterion is a condition over the + whole case's state that may become true at any time from any cause. As a + Petri net that is an n-to-n cross product of transitions, and its size is + quadratic in the plan. + +So a plan item entering `active` does exactly one of three things: + +| Type | Realisation | +| --- | --- | +| `humanTask` | create a task via the task capability, anchored to the same object, carrying candidates and deadline values | +| `stage` with a `flow` binding | `FlowRunService::queue()` against that flow's pinned published version | +| `milestone` | complete immediately — a milestone performs no work by definition (`PlanItemTransitions.php:80-83` gives it exactly two edges) | + +The case layer never writes a marking, never queues a transition, never +alters a run status. Dependency direction enforces it: `Service\Case\*` +depends on `Service\Flow\FlowRunService` and on the task service; nothing in +`Service\Flow\` depends on `Service\Case\`. The engine cannot reach the case +layer, so no run-path change can be introduced by accident. + +Coupling is one-directional: the realisation's terminal outcome drives the +plan item's terminal state; the plan item writes to its realisation only to +terminate it on exit or cascade. Nothing else. Two state machines that write +to each other is how they drift. + +### D-6 — A plan item is not a task; a task realises it + +Reusing `openregister_tasks` for every plan item is superficially attractive +— it already has the six states, `is_terminal`, `parent_task_id` and an +audit. Two facts defeat it: + +- **Repetition.** A repeating plan item produces N realisations while + remaining ONE plan item. One row cannot be both. +- **A milestone has no performer.** `flow-task-entity` makes `performer_type` + NOT NULL over `user|group|agent|worker`; a milestone would need a fifth, + non-performing value, which weakens a column whose whole point is that + every task has someone accountable for it. + +The clean reading is the one CMMN itself uses: the plan item is the +occurrence in the plan; the task is the work. That maps exactly onto +`flow-task-entity`'s template/instance split (`template_id`, +`template_version`, frozen `template_snapshot`) — the plan item is the +case-scoped template, the realisation is the instance. + +Drift between the two lifecycles is prevented by the one-directional rule in +D-5 plus one invariant asserted in tests: a plan item is terminal if and only +if all of its realisations are terminal. + +### D-7 — Discretionary and ad-hoc items are rows, and authorization is a decision + +`flow-task-entity` already made `run_uuid`/`node_id` optional provenance, and +that is exactly the property an ad-hoc item needs: its task points at no +node, because there is no node. Nothing has to be added to a pinned graph to +support one — which is D-5's argument reaching its conclusion. + +Two shapes, both first-class and distinguishable in the record: + +- **discretionary** — in the definition, not auto-entered, requires an act. + "Which can I enable right now?" is the reference's + `getEnableableDiscretionaryItems()` + (`procest/lib/Service/Cmmn/CaseModelEngine.php:269-276`): discretionary, + entry-satisfied, parent `active`. +- **ad-hoc** — in no definition. Authorization derives from the parent stage, + or from the plan root when parentless; an ad-hoc item cannot declare itself + unguarded, because "add an item nobody may block" is a privilege-escalation + primitive. + +The reference returns the authorization list for a caller to check — +`getPlanItemAuthorization()` returns `array` +(`CaseModelEngine.php:244-268`) and the REST layer does the comparison. We +invert that: `CasePlanAuthorizationService` DECIDES, fail-closed, before any +write. An unresolvable role or an unavailable group backend DENIES; there is +no nullable "could not determine" return a caller can read as "check +skipped". That pattern has its own gate in this fleet +(`hydra-gate-unsafe-auth-resolver`), and the reason this programme exists at +all is an endpoint that authorized the wrong question +(`lib/Controller/FlowRunController.php:423-436`). + +### D-8 — Stage completion, repetition, and a bounded cascade + +- **Auto-complete**: every REQUIRED child terminal AND no child `active`. The + "required" qualifier and the `$mandatoryFound` guard are both taken from + `PlanItemTree::stageMandatoryChildrenAllTerminal()` (`:98-117`) — a stage + with only optional children must NOT auto-complete on activation, and the + only thing distinguishing "all required children are terminal" from + "there are no required children" is that flag. +- **Exit cascade**: non-terminal children → `terminated`, unentered children + → `disabled`, each individually audited with the parent exit as cause. + Matches `PlanItemStateMachine::forceTerminateChildren()` (`:145`) and + `disableUnplannedDiscretionaryChildren()` (`:121`). +- **Bound**: evaluation is a fixpoint loop and needs a ceiling, as the + reference has at `PlanItemCascade.php:64` (`MAX_CASCADE_DEPTH = 50`). Ours + fails loudly at the bound with the bound named, and rolls back rather than + leaving a half-cascaded plan — the same posture as `MAX_TRANSITIONS` + (`FlowEngine.php:103`). +- **Repetition** produces a new realisation per repetition against one plan + item. The plan item is terminal only when its repetition rule is exhausted + AND every realisation is terminal. + +### D-9 — Zaaktype import is a mapping, not a client + +The mapper takes a `zaaktype` document that is ALREADY in a register and +returns a draft skeleton. It performs no HTTP. The reason is boundaries: +fetching from a remote ZTC is an integration concern with credentials, +retries and rate limits, and procest already owns that surface +(`ZtcController.php:982-989`). Making the mapper pure keeps it unit-testable +against fixtures and reusable by whoever did the fetching. + +| Zaaktype element | Becomes | Owned by | +| --- | --- | --- | +| `statustypen`, ordered by sequence number | milestones, in order | this change | +| `roltypen` (initiator, behandelaar, adviseur, beslisser) | candidate roles on human items, generic designation preserved | this change (the roles), `flow-task-entity` (the performer model) | +| `resultaattypen` | the constrained end-state set; completing outside it is refused | this change | +| `doorlooptijd`, `servicenorm` | deadline values carried onto items | STORED here, ACTED ON by `flow-business-timers` | +| archiving terms on `resultaattypen` | carried as metadata | out of scope — archival capabilities own it | +| everything else | a report entry | this change | + +The output is a **draft**. Importing never makes a definition live — +`flow-definition-versioning` already requires an explicit publish, and an +imported skeleton must go through it like anything else. + +**Reference process templates (bezwaar, Woo, vergunning) are NOT in this +change.** GEMMA publishes no importable BPMN process library — a documented +deliberate choice — so those templates are ours to author, and authoring +three correct Dutch statutory processes is domain work with its own review +cycle, not a side effect of building the mapper. Proposed follow-up: +`flow-case-reference-templates`. See Open Questions. + +## Data model + +`openregister_case_items` — one row per plan-item instance: + +| Group | Columns | +| --- | --- | +| Identity | `id` (PK), `uuid` (NOT NULL, unique), `item_key` (stable id within the plan, referenced by sentries), `name`, `description` | +| Anchor | `object_uuid` (NOT NULL), `register_id`, `schema_id` | +| Definition provenance | `flow_uuid`, `flow_version`, `definition_item_key`, `origin` (NOT NULL: `defined` \| `discretionary` \| `adhoc`) | +| Structure | `parent_item_id`, `plan_item_type` (NOT NULL: `stage` \| `humanTask` \| `milestone`), `position` | +| Lifecycle | `state` (NOT NULL, the six), `is_terminal` (NOT NULL bool), `entered_at`, `terminated_reason` | +| Criteria | `entry_criteria` (JSON), `exit_criteria` (JSON), `required` (NOT NULL bool, default true), `discretionary` (NOT NULL bool), `repetition` (JSON) | +| Realisation | `realisation_kind` (`task` \| `run` \| `none`), `realisation_uuid`, `realisation_count` | +| Authorization | `authorization` (JSON: the roles/groups that may enable or attach here) | +| Performer hints | `candidate_users` (JSON), `candidate_groups` (JSON), `candidate_role` — passed to the task on realisation, never evaluated here | +| Deadlines | `due_at`, `expires_at`, `doorlooptijd`, `servicenorm` — carried, not computed | +| Stamps | `created` (NOT NULL), `updated`, `created_by` | + +Indexes: `(object_uuid, is_terminal)` for "what is open on this case"; +`(object_uuid, parent_item_id)` for the tree read; `(plan_item_type, state)` +for "which cases are stuck where"; `(realisation_uuid)` for the reverse +lookup from a task; unique on `uuid`; unique on +`(object_uuid, item_key, realisation_count)` so a repetition cannot collide +with itself. + +No `overdue`, no `days_until_due`, no `days_overdue` — same rule as +`flow-task-entity`, same reason. + +`openregister_case_item_audit`: `id`, `case_item_id`, `from_state`, +`to_state`, `cause` (`sentry` \| `user` \| `realisation` \| `cascade` \| +`import`), `cause_ref` (the sentry id, the task uuid, the parent item uuid), +`actor`, `reason`, `authorized` (bool — denials are recorded too), `created`. +Append-only: no UPDATE and no DELETE path exists, and deleting a plan item +does not cascade to it. + +`openregister_tasks` gains NO column. The link runs plan item → task, via +`realisation_uuid`. + +## Seed Data (ADR-001) + +Fixtures for PHPUnit and for a demo instance. No new register or schema is +introduced by this change, so these are plan-item rows against existing +demo objects plus one zaaktype fixture for the mapper. All UUIDs are nil +placeholders (`00000000-0000-0000-0000-000000000000` with a varying final +group); all uids are obviously fake. + +1. **A two-stage municipal permit case** anchored to one demo object: + stage `intake` (active) containing a required humanTask + `completeness-check` (candidate group `demo-behandelaars`) and a + milestone `application-complete`; stage `assessment` (available) whose + single entry sentry has an on-part on `application-complete` completing. + Exercises nesting, milestone-satisfies-sentry, and required-child + completion. +2. **A discretionary advice item** under `assessment`: `external-advice`, + `discretionary: true`, `authorization: ["demo-beslissers"]`, still + `available`. Exercises the enableable-items query and the authorization + denial path for a non-member. +3. **An ad-hoc item on a live case**: `origin: adhoc`, no + `definition_item_key`, no `flow_uuid`, parented to `intake`, realised by a + task whose `run_uuid` is null. This fixture is the proof that the ad-hoc + path needs no flow definition at all. +4. **A terminated stage with its cascade**: a stage in `terminated` with one + child `terminated` and one `disabled`, each with an audit row whose + `cause` is `cascade` and whose `cause_ref` is the parent uuid. +5. **A repeating item**: one plan item with `realisation_count` 2, two task + realisations, one `completed` and one `active` — so "the plan item is + terminal iff all realisations are" has a live counter-example fixture. +6. **A zaaktype fixture** for the mapper: four `statustypen` with sequence + numbers deliberately out of document order, three `roltypen`, two + `resultaattypen`, a `doorlooptijd` and a `servicenorm`, plus two elements + the mapping does not cover so the report has content to assert on. + +All seeds are idempotent on uuid and install through the existing seeding +path. + +## Migration Plan + +Additive only. Two new tables; no existing table altered; no data +backfilled; nothing in `openregister_tasks`, `openregister_flows`, +`openregister_flow_runs` or `openregister_flow_state` is touched. The +`EventCatalogService` change appends three catalog entries, which widens +`knownTriggerIds()` and cannot invalidate a stored trigger. + +Rollback is dropping the two tables and reverting the catalog entries: no +existing behaviour depends on either, because there is no consumer in this +change. procest keeps running its own CMMN engine on `casePlanState` +untouched until its own migration change runs, so the two coexist by +construction rather than by coordination. + +The procest-side migration (`retire-cmmn-caseplanstate`, procest repo) is +where the interesting risk lives — decoding every `casePlanState` string into +rows, with a dry-run that reports items it cannot map and a period where the +blob is retained read-only for reconciliation. It is deliberately not in this +change: the target must exist and be tested before anything is drained into +it. + +## Risks / Trade-offs + +- **Two state machines (plan item and task) can drift** → one-directional + coupling (D-5) plus an invariant test: a plan item is terminal iff all its + realisations are terminal. Any code path that sets a task's non-terminal + state from the case layer is a review failure, and dependency direction + makes the reverse impossible. +- **Sentry evaluation is a fixpoint loop and could be expensive on a large + plan** → evaluation is scoped to one case (indexed by `object_uuid`), + bounded (D-8), and triggered by events rather than polled. A plan large + enough to hurt is a plan that should have been decomposed; the bound turns + that into a visible error instead of a slow request. +- **Extending the event catalog widens a hot path** → it does not: the + catalog is a constant read at validation and dispatch time, and + `openregister_flow_triggers`' match index is untouched. Case events do not + enter the trigger index. +- **The JSONLogic if-part is more expressive than the reference's + `{field, operator, value}`, so a badly written sentry can be subtler** → + `FlowExpression::isValid()` (`FlowExpression.php:166-183`) already runs at + save time, and an unevaluable expression is false at run time + (`:145-149`). The failure mode is "the item never enters", which is visible + in the case view, not "the item enters wrongly". +- **`realisation_count` in a unique key means a repetition rule change on a + live case can collide** → repetition is evaluated only forward, and a + definition change does not reach a live case (D-3's non-goal, inherited + from `flow-definition-versioning`). +- **We adopt a standard whose notation is receding** (Camunda dropped CMMN + in v8; ADR-065 D6) → that is the trade being made knowingly. The concepts + — plan item lifecycle, sentries, stages, milestones, discretionary work — + describe Dutch casework accurately and have no better-supported rival. The + notation, which is the part that is dying, is exactly the part we do not + adopt. +- **`FlowState` looked like the natural home for case state and is not** → + named here because the assumption is easy to make and cheap to act on + wrongly: it is UNIQUE on `flow_id` + (`lib/Migration/Version1Date20260731080000.php:104`), so every case of one + type would share one row. + +## Open Questions + +- **Do the reference process templates (bezwaar, Woo-verzoek, vergunning) + ship as a separate change or as seed data here?** Provisionally: a separate + change, `flow-case-reference-templates`, depending on this one. They are + domain artifacts needing legal review, and putting them here would put this + change's tasks over the 20-task ceiling for content that is not + engineering. Answering this later changes no spec and no task in this + change. +- **Should a case plan be exportable as BPMN?** Provisionally no — a case + plan is not a process graph, and exporting one as BPMN would produce a + diagram that misrepresents it. `flow-bpmn-interchange` continues to export + FLOWS. Revisit only on a named procurement requirement. +- **Where does a case-level SLA (as opposed to a per-item deadline) live?** + Provisionally `flow-business-timers`, since it already owns computation + over `doorlooptijd`/`servicenorm`; this change carries the values on the + item and on the anchor object either way. diff --git a/openspec/changes/flow-cmmn-case-semantics/proposal.md b/openspec/changes/flow-cmmn-case-semantics/proposal.md new file mode 100644 index 0000000000..00731b67d4 --- /dev/null +++ b/openspec/changes/flow-cmmn-case-semantics/proposal.md @@ -0,0 +1,205 @@ +--- +kind: code +depends_on: [flow-task-entity] +--- + +# Proposal: flow-cmmn-case-semantics + +## Summary + +Give OR Flow a **case layer**: a plan of stages, human tasks and milestones +whose entry and exit are governed by sentries, to which a caseworker may +attach work at runtime that no author drew. We adopt the CMMN 1.1 +**concepts** and none of its notation — no CMMN XML in, no CMMN XML out. +The execution substrate does not change: the case layer decides WHEN a plan +item becomes actionable, and the Petri net on symfony/workflow still does +every piece of work. A plan item is a row anchored to an OpenRegister +object; the task entity from `flow-task-entity` is how a human plan item is +realised. + +## Why + +**A Petri net cannot express a case.** OR Flow is a persisted Petri net: +`FlowGraph::inPlace()` makes a node's input place its own node id, and a +run's `marking` is therefore a set of node ids over a graph that +`flow-definition-versioning` deliberately freezes at queue time. That is the +right model for a process somebody drew. It is the wrong model for a +`bezwaarschrift` where the caseworker decides on day three to order an +external advice nobody put on the diagram: there is no node to put a token +in, and adding one would edit a pinned, immutable published version. + +**The fleet has already built the answer once, in the wrong place.** procest +runs a working CMMN engine — `procest/lib/Service/Cmmn/`, 2,004 lines across +nine classes: an exhaustive per-type transition table +(`PlanItemTransitions.php:59-84`), a sentry evaluator +(`SentryEvaluator.php`), a cascade with a depth bound +(`PlanItemCascade.php:64`), a stage-completion rule +(`PlanItemTree.php:98-117`). Under ADR-065 Decision 8 a leaf app that owns +an execution engine is a gate failure, and under ADR-098 Decision 1 this is +one of the seven runtimes that converge into OR. It is the reference +implementation for this change, not code to be reinvented. + +**And it stores its runtime state as a string.** The entire case plan lives +in one OR object field: `case.casePlanState`, declared `"type": "string"`, +`"visible": false` at `procest/lib/Settings/procest_register.json:1188-1192`, +written as `json_encode($ctx['state'])` in +`procest/lib/Service/Cmmn/CasePlanRepository.php:151-152`. Nothing can query +it. "Which cases are stuck in the advice stage?" requires decoding every +case. Two concurrent plan-item transitions are a read-modify-write over the +whole blob. This change replaces that string with rows. + +**Meanwhile the market payload has no home.** A Dutch municipality does not +arrive with a BPMN diagram; it arrives with a `zaaktype` from the VNG +Catalogi (ZTC) API — ordered `statustypen`, a `doorlooptijd` and a +`servicenorm`, `roltypen`, `resultaattypen`. procest already serves that +whole surface (`procest/lib/Controller/ZtcController.php:982-989` resolves +`eigenschappen`, `statustypen`, `resultaattypen`, `roltypen` per zaaktype) +and there is nothing to turn it into. GEMMA publishes no importable BPMN +process library, so "import your process" cannot mean BPMN for this buyer — +it has to mean the zaaktype. + +## What Changes + +- **A case plan is a tree of plan-item ROWS, not a JSON blob.** New table + `openregister_case_items` plus an append-only + `openregister_case_item_audit`. One row per plan-item instance, carrying + its `plan_item_type` (`stage` | `humanTask` | `milestone`), its `state` + (the same six CMMN states `flow-task-entity` already put on a task), its + `parent_item_id`, and its criteria. Queryable, indexable, and + transitionable one item at a time. +- **The case anchor is the OpenRegister object.** No `case` entity is + introduced and none is needed: a plan item carries + `object_uuid` + `register_id` + `schema_id`, the same triple a run already + carries as `subject_uuid`/`subject_register`/`subject_schema` + (`lib/Db/FlowRun.php:194-208`) and the same triple a task anchors on. The + zaak, the bezwaar, the vergunning IS the case. Camunda has to bolt a + "document" concept alongside its process instance to get this; we have it + for free because everything in OR is already an object. +- **A plan item's lifecycle is a table, not prose.** A pure + `CasePlanTransitions` service holding the per-type legal-edge table and + the terminal set, ported from `PlanItemTransitions.php:59-97` — including + its asymmetry: a milestone may only go `available → completed` or + `available → terminated`, because a milestone performs no work. +- **Sentries reuse the engine's existing primitives. No new condition + language.** A sentry's **if-part** is a JSONLogic expression evaluated by + `FlowExpression::isTrue()` (`lib/Service/Flow/FlowExpression.php:145-160`), + which already returns false for an expression it could not evaluate. A + sentry's **on-part** is an entry in the flow event catalog + (`lib/Service/Flow/EventCatalogService.php:52-69`), which this change + extends with `case.item.completed` / `.terminated` / `.disabled`. procest's + own `{field, operator, value}` dialect + (`SentryEvaluator.php:178-196`: `eq|neq|gt|gte|lt|lte|in|notIn|truthy|falsy`) + is deliberately NOT adopted — it would be a fourth condition dialect in a + fleet that is already paying for three (openregister#2787). +- **Stages and milestones.** A stage nests plan items and has its own entry + and exit criteria; a milestone marks that a state has been reached, + performs no work, and can itself satisfy another item's sentry. Stage + completion has a written rule (required children terminal, no child + active) rather than an arrow somebody forgot to draw. +- **Discretionary / ad-hoc items.** A caseworker attaches work to a live + case that exists in no definition. This works because `flow-task-entity` + already made `run_uuid`/`node_id` OPTIONAL provenance — an ad-hoc task + needs no node to point at, so nothing has to be added to the pinned graph + to support one. Who may attach what is an authorization decision on the + item, modelled on `CaseModelEngine::getPlanItemAuthorization()` + (`procest/lib/Service/Cmmn/CaseModelEngine.php:244-268`) and enforced + fail-closed rather than returned for a caller to interpret. +- **Realisation, not execution.** A plan item entering `active` does exactly + one of three things: create a task through `TaskService` (humanTask), + queue a flow run against a pinned published version (a stage bound to a + flow), or complete immediately (milestone). The case layer never advances + a marking, never queues a transition and never touches the engine's + scheduler. +- **Zaaktype → case skeleton.** A mapper turning a VNG Catalogi `zaaktype` + document that is already in a register into a case-plan skeleton: + `statustypen` in `volgnummer` order become milestones, `roltypen` become + candidate roles on human items, `resultaattypen` become the constrained + end states, and `doorlooptijd`/`servicenorm` are CARRIED onto the item for + `flow-business-timers` to act on. The import produces a report of what it + could not map, in the same spirit as the BPMN importer's lossy-mapping + report. +- **Business state is written through, never owned.** Runtime plan state + lives in `openregister_case_items`; the business facts a citizen or an + auditor can see (the zaak's status, its resultaat) are mirrored onto the + register object through the ordinary object-write path. The register is + the record; the engine is not (Common Ground vijflaagsmodel, ADR-022). + +## What does NOT change + +- **The task entity and `TaskService`** — `flow-task-entity`. This change + consumes them; it adds no lifecycle verb, no performer type and no inbox + query. +- **The `openregister.user-task` node** — `flow-user-task-node`. A stage may + queue a flow that contains one; the case layer does not define it. +- **Timers.** `doorlooptijd` and `servicenorm` are mapped and stored here; + business-day arithmetic, escalation matrices and breach sweeps are + `flow-business-timers`. +- **The Petri net.** No node type is added, no marking semantics change, no + `MAX_TRANSITIONS` or oversight behaviour is touched. ADR-098 Decision 4 is + explicit that the execution substrate stays the Petri net, and the whole + design of this change exists to keep that true. +- **procest's migration onto this layer.** Retiring + `procest/lib/Service/Cmmn/` and draining `case.casePlanState` into + `openregister_case_items` is a **procest-side change** — proposed slug + `procest/openspec/changes/retire-cmmn-caseplanstate` — with its own + backfill and its own rollback. Nothing in procest is touched here. +- **CMMN XML.** Not parsed, not serialised, not exported. ADR-065 Decision 6 + established the ground: no PHP CMMN implementation exists (all 15 + Packagist hits are substring false positives), no PHP FEEL parser exists + at all, and Camunda dropped CMMN in v8 — the notation is receding while + the concepts are not. We take the concepts. A CMMN XML export is + reconsidered only if a buyer asks for one by name. +- **`flow-bpmn-interchange`.** It stays as written. BPMN is a FORMAT, never + an execution semantic. One follow-up edit belongs to THAT change and not + to this one: its import table currently maps `bpmn:userTask` → + `await-signal` (`openspec/changes/flow-bpmn-interchange/design.md`, + Decision 2) because no user-task node existed when it was written; once + `flow-user-task-node` lands, that row's target becomes + `openregister.user-task`. + +## Capabilities + +### New Capabilities +- `flow-cases`: the case layer — plan items anchored to an OpenRegister + object, their CMMN lifecycle and transition table, sentries over the + engine's existing expression and event primitives, stages, milestones, + discretionary items and their authorization, stage/case completion rules, + the zaaktype-to-skeleton mapping, and write-through of business state to + the register. + +### Modified Capabilities + + +## Impact + +- **Affected code**: new `lib/Db/CaseItem.php` + `CaseItemMapper.php`, + `CaseItemAudit.php` + `CaseItemAuditMapper.php`; new + `lib/Service/Case/` — `CasePlanService.php`, + `CasePlanTransitions.php`, `CasePlanStateMachine.php`, + `CaseSentryEvaluator.php`, `CasePlanAuthorizationService.php`, + `CasePlanCascade.php`, `ZaaktypeCaseSkeletonMapper.php`; new + `lib/Controller/CaseController.php` + `appinfo/routes.php` entries; one + migration. `lib/Service/Flow/EventCatalogService.php:52-69` gains the + `case.item.*` entries — additive, and its `aliasesFor()` contract is + unchanged. +- **Affected data**: two new tables. No existing table altered; no data + backfilled. `openregister_tasks` gains no column — the link runs the other + way, from a plan item to the task uuid it created. +- **Affected apps**: none in this change. procest is the first consumer and + migrates in its own repo; opencatalogi and softwarecatalog are unaffected + (additive migration only). +- **Depends on**: `flow-task-entity` — a human plan item's realisation IS a + task row, and the optional `run_uuid`/`node_id` on that entity is what + makes a discretionary item expressible without editing a pinned graph. + Transitively `flow-definition-versioning`, whose immutable published + version is the reason a runtime-added item must be a row and not a node. +- **ADRs**: ADR-098 D4 (this change is that decision), D1 (one engine — + procest's CMMN converges here), D2 (the task entity realises a human plan + item); ADR-065 D6 (CMMN was deferred because nothing existed to buy; we + adopt concepts, not notation) and D8 (a leaf app owning an engine is a + gate failure); ADR-031 (declarative-vs-imperative — argued in design.md); + ADR-022 (apps consume OR abstractions; the register owns business state); + ADR-011 (reuse before implement — the if-part reuses `FlowExpression`). diff --git a/openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md b/openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md new file mode 100644 index 0000000000..15dd31684f --- /dev/null +++ b/openspec/changes/flow-cmmn-case-semantics/specs/flow-cases/spec.md @@ -0,0 +1,449 @@ +## Purpose + +A case layer over the flow engine: a plan of stages, human tasks and +milestones anchored to an OpenRegister object, whose entry and exit are +governed by sentries and to which a caseworker may attach work at runtime +that no author drew. It adopts the CMMN 1.1 concepts and none of its +notation; the Petri net remains the execution substrate. + +## ADDED Requirements + +### Requirement: The case is the OpenRegister object + +A case plan SHALL be anchored to exactly one OpenRegister object, +identified by the triple object uuid + register + schema. The system SHALL +NOT introduce a separate case record, a case identifier, or a case +lifecycle distinct from the anchoring object's own. + +Every plan item in a case plan SHALL carry that same anchor triple, so that +"what is running on this object?" is answerable by one indexed lookup and +without joining through a flow run. A case plan SHALL be resolvable for an +object that has never had a flow run. + +The anchor SHALL be the same shape a flow run already uses to name its +subject, so a plan item, a task and a run about the same object agree on +what they are about without translation. + +#### Scenario: A case plan is found by its object + +- **GIVEN** an object with a case plan containing a stage, two human items + and a milestone +- **WHEN** the case plan is requested by the object's uuid +- **THEN** the response MUST contain every plan item with its current state, + its type and its parent +- **AND** the response MUST NOT require a flow run uuid to be supplied +- @e2e covered by the case-plan route scenario in the case-plan Playwright + spec + +#### Scenario: A case plan exists without any flow run + +- **GIVEN** an object with plan items created directly, none of which + carries a run reference +- **WHEN** the case plan is read and an item is transitioned +- **THEN** both MUST succeed +- **AND** no code path MUST treat the absence of a run as an error or as a + degraded case +- @e2e exclude covered by CasePlanService unit tests over run-less plans + +### Requirement: Plan-item state is stored as rows, never as an encoded blob + +Each plan-item instance SHALL be a persisted record with its own identity, +its own state column and its own audit trail. The system SHALL NOT store a +case's runtime plan state as an encoded string, an encoded document, or any +single field holding the state of more than one plan item. + +Two plan items in the same case SHALL be transitionable concurrently +without either transition reading, rewriting or overwriting the other's +state. + +Plan-item state SHALL be queryable by state, by type, by parent and by +anchor object without decoding a stored document. Answering "which cases +have an active item of type X?" SHALL be an indexed query. + +Every plan-item state change SHALL append an audit entry naming the item, +the from-state, the to-state, the acting identity, the cause (a sentry id, +a user action, a realisation outcome, or a cascade from a parent) and the +timestamp. Audit entries SHALL NOT be updatable or deletable through any +API. + +#### Scenario: Concurrent transitions on one case do not clobber each other + +- **GIVEN** a case plan with two active human items +- **WHEN** both are completed in overlapping requests +- **THEN** both MUST be recorded as completed +- **AND** each MUST have its own audit entry +- @e2e exclude covered by a concurrency unit test over two overlapping + transitions + +#### Scenario: Stuck cases are found by query + +- **GIVEN** a population of cases of which some have an item of a given type + in state `active` +- **WHEN** those cases are listed by item type and state +- **THEN** the query MUST be answered from the datastore, with filtering, + sorting, pagination and the total all computed there +- **AND** no stored document MUST be decoded to evaluate the filter +- @e2e exclude covered by CaseItemMapper query tests + +### Requirement: One lifecycle table governs every plan item + +A plan item's state SHALL be one of `available`, `enabled`, `active`, +`completed`, `terminated`, `disabled` — the same six states a task carries. +Every plan item SHALL start in `available`. + +Legal transitions SHALL be defined by an exhaustive table keyed by plan-item +type, and presence in that table SHALL be the only definition of legality. A +transition absent from the table — including a transition from a state to +itself — SHALL be REFUSED with an error naming the item, its type, the +from-state and the requested to-state. It SHALL NOT be silently ignored and +SHALL NOT be coerced to a legal neighbour. + +`completed`, `terminated` and `disabled` SHALL be terminal: no transition +out of them SHALL exist for any plan-item type. + +A `milestone` SHALL have exactly two legal transitions, `available → +completed` and `available → terminated`. A milestone SHALL NOT be +enableable, activatable or disableable, because a milestone performs no +work and therefore has nothing to be active during. + +#### Scenario: An illegal transition names all four facts + +- **GIVEN** a milestone in state `available` +- **WHEN** it is transitioned to `active` +- **THEN** the transition MUST be refused with an error naming the item id, + the type `milestone`, the from-state and the to-state +- **AND** the item's state MUST be unchanged +- @e2e exclude covered by the transition-table unit test matrix + +#### Scenario: A terminal item accepts nothing further + +- **GIVEN** a human plan item in state `completed` +- **WHEN** any transition is requested on it +- **THEN** it MUST be refused naming the current state +- @e2e exclude covered by the transition-table unit test matrix + +### Requirement: Sentries are entry and exit criteria over existing engine primitives + +A plan item SHALL carry an ordered set of entry criteria and an ordered set +of exit criteria. Each criterion (a sentry) SHALL consist of an optional +on-part and an optional if-part. + +A sentry SHALL fire when its on-part has occurred AND its if-part evaluates +true — conjunction WITHIN one sentry. An item's entry or exit SHALL be +satisfied when ANY of its sentries fires — disjunction ACROSS the set. An +empty criteria set SHALL mean "satisfied as soon as the parent is active" +for entry, and "never satisfied" for exit; these two defaults SHALL be +stated in the stored definition rather than inferred by each caller. + +The **if-part** SHALL be expressed in the flow engine's existing expression +language, evaluated against a document containing the anchoring object's +current state and the case plan's current item states. The system SHALL NOT +introduce a second condition language, a second operator vocabulary or a +second expression evaluator for sentries. An if-part that cannot be +evaluated SHALL be treated as FALSE, never as vacuously true. + +The **on-part** SHALL be an event drawn from the flow engine's existing +event catalog. The catalog SHALL be extended with plan-item lifecycle +events for `completed`, `terminated` and `disabled`, so that one plan item's +outcome can satisfy another item's sentry. A sentry naming an event that is +not in the catalog SHALL be REFUSED at save time, not at run time. + +A malformed sentry — one with neither an on-part nor an if-part, or with an +if-part naming no field — SHALL never fire. + +#### Scenario: A milestone satisfies another item's entry + +- **GIVEN** a milestone `advice-received` and a human item whose only entry + sentry has an on-part on that milestone completing +- **WHEN** the milestone is completed +- **THEN** the human item MUST become actionable without any further call +- **AND** its audit entry MUST name the sentry that admitted it +- @e2e covered by the sentry-cascade scenario in the case-plan Playwright + spec + +#### Scenario: An unevaluable condition blocks rather than admits + +- **GIVEN** an entry sentry whose if-part references a field the anchoring + object does not have +- **WHEN** the case plan is evaluated +- **THEN** the sentry MUST NOT fire +- **AND** the item MUST remain unentered +- @e2e exclude covered by sentry-evaluation unit tests + +#### Scenario: An unknown event is refused at save time + +- **GIVEN** a case-plan definition with a sentry naming an event that is not + in the event catalog +- **WHEN** the definition is saved +- **THEN** the save MUST be refused with an error naming the unknown event +- **AND** no plan item MUST be created +- @e2e exclude covered by case-plan definition validation unit tests + +### Requirement: Stages nest, and complete by a written rule + +A `stage` SHALL contain other plan items, SHALL itself be a plan item with +its own entry and exit criteria, and SHALL be nestable to arbitrary depth. +A child SHALL NOT become actionable while its parent stage is not `active`. + +A stage SHALL complete automatically when every REQUIRED child is terminal +and no child is `active`. A stage whose children are ALL optional SHALL NOT +auto-complete on activation: absence of required children SHALL be treated +as "not complete", never as "trivially complete". + +Exiting a stage — by its own exit criteria, by termination, or by its parent +terminating — SHALL cascade: every non-terminal child SHALL be terminated, +and every child not yet entered SHALL be disabled. Each cascaded transition +SHALL be individually audited with the parent exit as its cause. + +Cascading evaluation SHALL be bounded. A definition whose sentries produce +an unbounded cascade SHALL fail with an error naming the bound, and SHALL +NOT loop. + +A plan item SHALL declare whether it is required. Required-ness SHALL govern +only the parent's completion rule and SHALL NOT make an item auto-start. + +A plan item MAY declare a repetition rule. A repeating item SHALL produce a +new realisation per repetition while remaining ONE plan item, and each +realisation SHALL be individually addressable. + +#### Scenario: A stage with only optional children stays open + +- **GIVEN** a stage that becomes `active` and contains only discretionary + children, none of which has been enabled +- **WHEN** the case plan is evaluated +- **THEN** the stage MUST remain `active` +- @e2e exclude covered by stage-completion unit tests + +#### Scenario: Terminating a stage terminates what is under it + +- **GIVEN** an active stage with one active human item and one unentered + milestone +- **WHEN** the stage is terminated +- **THEN** the human item MUST be `terminated` and the milestone MUST be + `disabled` +- **AND** each MUST carry an audit entry naming the stage exit as the cause +- @e2e covered by the stage-termination scenario in the case-plan Playwright + spec + +#### Scenario: An unbounded cascade fails loudly + +- **GIVEN** a definition whose sentries admit each other in a cycle +- **WHEN** the case plan is evaluated +- **THEN** evaluation MUST stop at the declared bound and report an error + naming it +- **AND** the case plan MUST NOT be left partially transitioned +- @e2e exclude covered by cascade-bound unit tests + +### Requirement: A human plan item is realised by a task, and a stage may be realised by a flow run + +The case layer SHALL NOT execute work. When a plan item becomes `active` it +SHALL do exactly one of: + +- **humanTask** — create a task through the task capability, carrying the + case anchor, the item's candidate performers, and the item's deadline + values; +- **stage bound to a flow** — queue a flow run against the flow's pinned + published version; +- **milestone** — complete immediately, performing no work. + +The case layer SHALL NOT advance a marking, queue a transition, alter a +run's status, or participate in the engine's scheduling in any other way. + +The coupling SHALL be one-directional: the realisation's terminal outcome +SHALL drive the plan item's terminal state, and the plan item SHALL write to +its realisation only to TERMINATE it when the item is exited or cascaded. +The plan item SHALL NOT otherwise set a task's state, and a task SHALL NOT +set a plan item's non-terminal state. + +A plan item's realisation SHALL be recorded on the plan item, so that a task +and the plan item that produced it are mutually resolvable. + +#### Scenario: Completing the task completes the item + +- **GIVEN** an active human plan item whose task has been created +- **WHEN** the task is completed by its performer +- **THEN** the plan item MUST become `completed` +- **AND** the plan item's audit entry MUST name the task completion as the + cause +- @e2e covered by the task-realisation scenario in the case-plan Playwright + spec + +#### Scenario: Exiting an item terminates its open task + +- **GIVEN** an active human plan item with an open task in someone's inbox +- **WHEN** the item's exit criteria fire +- **THEN** the task MUST be terminated with a reason +- **AND** it MUST no longer appear in that person's inbox +- @e2e exclude covered by realisation-termination unit tests + +#### Scenario: The case layer touches no marking + +- **GIVEN** a case plan being evaluated over a run that is suspended +- **WHEN** every plan item that can transition has transitioned +- **THEN** the run's marking, status and log MUST be byte-identical to what + they were before +- @e2e exclude covered by a unit test asserting run immutability across + case-plan evaluation + +### Requirement: A caseworker may attach work no author drew + +A plan item MAY be declared discretionary: present in the definition but not +entered automatically, requiring an explicit act to enable it. The system +SHALL be able to list, for a given case, exactly which discretionary items +are currently enableable — those whose parent is `active` and whose entry +criteria are satisfied. + +The system SHALL additionally allow an AD-HOC item: a plan item attached to +a live case that appears in no definition at all. An ad-hoc item SHALL be a +first-class plan item, subject to the same lifecycle table, the same audit +and the same completion rules as a defined one, and SHALL be distinguishable +from a defined item in the record and in the API. + +Adding an ad-hoc item SHALL NOT modify any flow definition, SHALL NOT create +a new definition version, and SHALL NOT alter the graph any in-flight run is +pinned to. + +Enabling a discretionary item and adding an ad-hoc item SHALL each be +authorized fail-closed against the item's declared authorization before +anything is written. An authorization answer that cannot be determined — an +unresolvable role, an unavailable group backend — SHALL DENY. The +authorization decision SHALL be made by the system, not returned to a caller +to interpret. + +An ad-hoc item's authorization SHALL derive from its parent stage, or from +the case plan root when it has no parent; an ad-hoc item SHALL NOT be able +to declare itself unguarded. + +#### Scenario: A caseworker adds an unplanned advice request + +- **GIVEN** a live case whose definition contains no external-advice item +- **WHEN** an authorized caseworker attaches an ad-hoc human item to the + active stage +- **THEN** the item MUST be created, entered and realised as a task +- **AND** no flow definition and no definition version MUST change +- @e2e covered by the ad-hoc-item scenario in the case-plan Playwright spec + +#### Scenario: An unauthorized attach is refused before anything is written + +- **GIVEN** a user who does not hold the parent stage's authorization +- **WHEN** they attempt to enable a discretionary item or attach an ad-hoc + one +- **THEN** the attempt MUST be refused +- **AND** no plan item, task or audit-visible state change MUST exist + afterwards other than the recorded denial +- @e2e exclude covered by CasePlanAuthorizationService unit tests + +#### Scenario: An indeterminate authorization denies + +- **GIVEN** a discretionary item guarded by a role that cannot be resolved +- **WHEN** enabling it is attempted +- **THEN** it MUST be denied +- @e2e exclude covered by CasePlanAuthorizationService unit tests + +### Requirement: Business state is written through to the register, never owned by the engine + +Runtime plan state SHALL live in the case layer's own records. Business +state that a citizen, a caseworker or an auditor relies on — the anchoring +object's status, its result, the moment each was reached — SHALL be mirrored +onto the register object through the ordinary object-write path. + +The register object SHALL be the record of the business fact. The case layer +SHALL NOT be a system of record for anything a consumer of the register +needs, and no consumer SHALL be required to read plan-item rows to learn the +object's business status. + +The mirror SHALL be one-directional. A write to the register object SHALL +NOT be interpreted as a plan-item transition; it MAY satisfy a sentry, which +is a different thing and goes through sentry evaluation like any other +condition. + +Deleting or archiving a case's plan items SHALL NOT remove or alter the +business state already mirrored onto the object. + +#### Scenario: The object carries the status without the plan + +- **GIVEN** a case whose plan has advanced through two milestones +- **WHEN** the anchoring object is read through the ordinary object API by a + consumer that knows nothing about plan items +- **THEN** the object MUST carry the current business status and the moment + it was reached +- @e2e covered by the write-through scenario in the case-plan Playwright spec + +#### Scenario: Removing the plan does not rewrite history + +- **GIVEN** a case whose plan items are deleted +- **WHEN** the anchoring object is read +- **THEN** its mirrored business status MUST be unchanged +- @e2e exclude covered by write-through unit tests + +### Requirement: A zaaktype maps to a case skeleton, and reports what it could not map + +The system SHALL produce a case-plan skeleton from a VNG Catalogi +`zaaktype` document that is already present in a register. The mapping SHALL +be a pure transformation over a supplied document; the system SHALL NOT +fetch from a remote catalogue as part of this capability. + +The mapping SHALL be: + +- `statustypen`, in their declared order, become milestones in that order; +- `roltypen` become candidate roles on human plan items, preserving the + generic role designation where one is present; +- `resultaattypen` become the constrained set of end states the case may + finish in — a case SHALL NOT be completable with a result outside that + set; +- `doorlooptijd` and `servicenorm` are CARRIED onto the produced items as + deadline values. This capability SHALL store them and SHALL NOT compute, + schedule, escalate or sweep on them. + +The import SHALL produce a report naming every element it could not map, +every element it mapped approximately, and what the author should do about +each. A zaaktype element outside the mapping SHALL be reported, not silently +dropped and not guessed at. + +The produced skeleton SHALL be a draft the author edits, never a definition +that becomes live by the act of importing. + +#### Scenario: An ordered status list becomes ordered milestones + +- **GIVEN** a zaaktype with four `statustypen` carrying sequence numbers out + of document order +- **WHEN** the skeleton is produced +- **THEN** the skeleton MUST contain four milestones in sequence-number + order +- @e2e exclude covered by the zaaktype-mapping unit test fixtures + +#### Scenario: A result outside the zaaktype's set is refused + +- **GIVEN** a case produced from a zaaktype declaring three + `resultaattypen` +- **WHEN** the case is completed with a result that is not one of the three +- **THEN** the completion MUST be refused naming the allowed set +- @e2e exclude covered by end-state constraint unit tests + +#### Scenario: Unmappable content is reported, never dropped silently + +- **GIVEN** a zaaktype carrying elements the mapping does not cover +- **WHEN** the skeleton is produced +- **THEN** the report MUST name each such element and why it was not mapped +- **AND** the skeleton MUST be marked as a draft +- @e2e exclude covered by the zaaktype-mapping report unit tests + +### Requirement: CMMN notation is not adopted, and BPMN remains a format + +The system SHALL NOT parse CMMN XML, SHALL NOT serialise CMMN XML, and SHALL +NOT depend on any CMMN notation artifact. The case layer's definition format +SHALL be the system's own, and the CMMN standard SHALL be present as +vocabulary and semantics only. + +BPMN SHALL remain an interchange FORMAT and SHALL NOT become an execution +semantic. No construct imported from BPMN SHALL execute differently from the +same construct authored natively, and nothing in the case layer SHALL be +reachable only via a BPMN document. + +#### Scenario: No CMMN document is accepted or produced + +- **GIVEN** a request to import or export a case plan as CMMN XML +- **WHEN** it is made against any endpoint in this capability +- **THEN** no such endpoint MUST exist +- @e2e exclude covered by the route inventory test asserting the absence of + CMMN endpoints diff --git a/openspec/changes/flow-cmmn-case-semantics/tasks.md b/openspec/changes/flow-cmmn-case-semantics/tasks.md new file mode 100644 index 0000000000..66611575c3 --- /dev/null +++ b/openspec/changes/flow-cmmn-case-semantics/tasks.md @@ -0,0 +1,208 @@ +# Tasks: flow-cmmn-case-semantics + +## 1. Storage + +- [ ] 1.1 Migration creating `openregister_case_items` and + `openregister_case_item_audit` with the columns and indexes in + design.md — Data model. Additive only: no existing table altered, no + data backfilled, and `openregister_tasks` gains NO column. Verify + neither table has an `overdue`, `days_until_due` or `days_overdue` + column. +- [ ] 1.2 Entities + mappers under `lib/Db/`: `CaseItem`/`CaseItemMapper`, + `CaseItemAudit`/`CaseItemAuditMapper`, following `lib/Db/FlowRun.php` + conventions (docblock `@method` block, `@spec` tag, `@license + EUPL-1.2` + `@copyright 2026 Conduction B.V.`, `jsonSerialize()`). + `CaseItemAuditMapper` exposes NO update and NO delete method. Tree, + anchor and "stuck where" reads are mapper queries — filtering, + sorting, pagination and totals all in the datastore. + +## 2. Plan-item lifecycle + +- [ ] 2.1 `lib/Service/Case/CasePlanTransitions.php` — pure, stateless, + injected as a collaborator: the six states, the three types, the + exhaustive per-type edge table and the explicit terminal set, ported + from `procest/lib/Service/Cmmn/PlanItemTransitions.php:41-97` + including the milestone asymmetry (two edges only). `assertLegal()` + throws naming item, type, from-state and to-state; a same-state + "transition" is illegal. +- [ ] 2.2 `lib/Service/Case/CasePlanStateMachine.php` — one transition, + `is_terminal` written in the same statement as `state`, audit row + appended in the SAME transaction as the mutation (successes AND + denials, `authorized: false`), and stage-exit cascade: non-terminal + children terminated, unentered children disabled, each individually + audited with `cause: cascade` and the parent uuid as `cause_ref`. + References `PlanItemStateMachine.php:121-170`. + +## 3. Sentries + +- [ ] 3.1 Append `case.item.completed`, `case.item.terminated`, + `case.item.disabled` to `EventCatalogService::CATALOG` + (`lib/Service/Flow/EventCatalogService.php:52-69`) and emit them from + the state machine. Purely additive: `aliasesFor()` behaviour unchanged + and no row enters `openregister_flow_triggers`. +- [ ] 3.2 `lib/Service/Case/CaseSentryEvaluator.php` — AND within a sentry, + OR across the criteria array; on-part resolved against current + plan-item state for the monotonic terminal events and against the + event being handled otherwise; if-part evaluated ONLY through + `FlowExpression::isTrue()` over a `dataFor()` document extended with a + `case` key. No new operator vocabulary, no second evaluator. A + malformed sentry never fires. Save-time validation lands here too: a + sentry naming an event outside `knownTriggerIds()` + (`EventCatalogService.php:85`) is REFUSED naming the event, and an + if-part is checked with `FlowExpression::isValid()` — the editor + fails, not the run. + +## 4. Item kinds and completion rules + +- [ ] 4.1 `lib/Service/Case/CasePlanCascade.php` — the fixpoint evaluation + loop with a named bound (reference: `PlanItemCascade.php:64`, + `MAX_CASCADE_DEPTH = 50`), failing loudly at the bound and rolling + back rather than leaving a half-cascaded plan. +- [ ] 4.2 Stage semantics: nesting to arbitrary depth, a child never + actionable while its parent is not `active`, and auto-completion when + every REQUIRED child is terminal AND no child is `active` — including + the `$mandatoryFound` guard so a stage with only optional children + does NOT auto-complete on activation + (`PlanItemTree.php:98-117`). Milestones land here too: no work + performed, completion immediate on entry, and completion emits the + catalog event so another item's sentry can consume it. +- [ ] 4.3 Repetition: a repeating plan item produces a new realisation per + repetition while remaining ONE plan item, each realisation + individually addressable via `realisation_count`; the item is terminal + only when the rule is exhausted AND every realisation is terminal. + +## 5. Realisation + +- [ ] 5.1 A `humanTask` item entering `active` creates a task through the + task capability, carrying the case anchor triple, the candidate + users/groups/role and the deadline values. The task's terminal outcome + drives the item's terminal state; the item writes to the task ONLY to + terminate it on exit or cascade. Nothing else in either direction. +- [ ] 5.2 A `stage` bound to a flow queues a run through + `FlowRunService::queue()` (`lib/Service/Flow/FlowRunService.php:321`) + against the flow's pinned published version; the run's terminal status + drives the item. Assert by dependency direction that nothing under + `lib/Service/Flow/` depends on `lib/Service/Case/`. + +## 6. Discretionary, ad-hoc and authorization + +- [ ] 6.1 `lib/Service/Case/CasePlanAuthorizationService.php` — DECIDES + fail-closed before any write, denying on indeterminate (unresolvable + role, unavailable group backend). No nullable "could not determine" + return a caller can read as "check skipped". Contrast the reference, + which returns a list for the REST layer to compare + (`CaseModelEngine.php:244-268`). +- [ ] 6.2 Enable-a-discretionary-item and attach-an-ad-hoc-item verbs, plus + the enableable-items query (discretionary, entry-satisfied, parent + `active` — `CaseModelEngine.php:269-276`). An ad-hoc item derives its + authorization from its parent stage or the plan root and CANNOT + declare itself unguarded; creating one modifies no flow definition and + creates no definition version. + +## 7. Write-through and API + +- [ ] 7.1 Business-state write-through: the anchoring object's status and + result are mirrored via the ordinary object-write path so + `x-openregister-lifecycle` governs them and `object.transitioned` + fires as usual. One-directional — an object write is never read back + as a plan-item transition, though it may satisfy a sentry. Deleting + plan items leaves mirrored state intact. +- [ ] 7.2 `lib/Controller/CaseController.php` + `appinfo/routes.php`: read + the plan by object uuid, transition an item, enable a discretionary + item, attach an ad-hoc item, list enableable items, list cases by item + type and state. Every method declares its auth posture attribute AND + routes its real check through `CasePlanAuthorizationService` — the + attribute is never the whole check. No CMMN XML endpoint exists. + +## 8. Zaaktype import + +- [ ] 8.1 `lib/Service/Case/ZaaktypeCaseSkeletonMapper.php` — a PURE + transformation over a supplied zaaktype document, no HTTP: ordered + `statustypen` → milestones in sequence order; `roltypen` → candidate + roles preserving the generic designation; `resultaattypen` → the + constrained end-state set (completing outside it is refused); + `doorlooptijd`/`servicenorm` carried onto items and NOT computed on. + Includes the mapping report and the draft rule: every unmapped and + approximately-mapped element named with what the author should do, + nothing silently dropped or guessed, and the produced skeleton marked + as a draft that only an explicit publish makes live. Its endpoint is + routed with 7.2 and carries the same authorization posture. + +## 9. Seed data + +- [ ] 9.1 Install the six seed fixtures from design.md — Seed Data (the + two-stage permit case, the discretionary advice item, the ad-hoc item + on a run-less task, the terminated stage with its cascade audit rows, + the repeating item with two realisations, and the zaaktype fixture + with out-of-order sequence numbers and unmappable elements) through + the existing seeding path, idempotent on uuid, nil-placeholder UUIDs + and obviously fake uids. + +## 10. Tests + +- [ ] 10.1 Table-driven unit tests for the transition table (every legal + edge, and the illegal ones naming all four facts — including + milestone → `active` and any transition out of a terminal state) and + for sentry evaluation (AND-within / OR-across, an unevaluable if-part + being FALSE, an unknown event refused at save time, a malformed sentry + never firing). +- [ ] 10.2 Structural and authorization tests: a stage with only optional + children staying open; the cascade bound failing loudly and rolling + back; a non-member denied on enable and on attach with the denial + audited; an unresolvable role denying; two overlapping transitions on + different items both succeeding with their own audit rows; the + invariant that a plan item is terminal iff all its realisations are. Also + here: a run's `marking`, `status` and `log` byte-identical across a full + case-plan evaluation; the zaaktype fixture producing sequence-ordered + milestones, a refused out-of-set result and a report naming both + unmappable elements; and opencatalogi + softwarecatalog suites green + (additive-migration-only consumers — the check is that no shared service + signature changed). +- [ ] 10.3 Playwright coverage for the six `@e2e`-marked scenarios in + `specs/flow-cases/spec.md`: reading a case plan by object uuid, a + milestone satisfying another item's sentry, terminating a stage + cascading to its children, completing a task completing its plan item, + attaching an ad-hoc item, and the object carrying mirrored business + status. + +## Acceptance criteria + +- No case identity exists other than the anchoring object's. Every plan item + is reachable by object uuid, and a case plan works for an object that has + never had a flow run. +- No plan state is stored as an encoded document anywhere. "Which cases have + an active item of type X?" is answered by an indexed query with the total + computed in the datastore. +- Nothing under `lib/Service/Flow/` references `lib/Service/Case/`, and a + full case-plan evaluation leaves every run's marking, status and log + unchanged. +- A discretionary or ad-hoc item is created without editing any flow + definition and without creating any definition version. +- Every enable and attach verb denies before writing when the authorization + answer cannot be determined; no verb is reachable by knowing a uuid alone. +- The only condition language used by a sentry if-part is the engine's + existing JSONLogic; grep finds no second operator vocabulary under + `lib/Service/Case/`. +- No endpoint, service or test parses or emits CMMN XML. +- `openregister_tasks`, `openregister_flows`, `openregister_flow_runs` and + `openregister_flow_state` are unchanged by this work, and procest's own + CMMN implementation is untouched. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- Every new PHP file carries `@license EUPL-1.2` and + `@copyright 2026 Conduction B.V.`; every public/protected method carries a + `@spec openspec/specs/flow-cases/spec.md` anchor. +- Regression check against opencatalogi and softwarecatalog: both are + additive-migration-only consumers here, so the check is that their suites + are green and no shared service signature changed. +- Depends on `flow-task-entity` (a human plan item's realisation IS a task, + and its optional `run_uuid`/`node_id` is what makes an ad-hoc item + expressible) and transitively on `flow-definition-versioning` (whose + immutable published version is why a runtime-added item must be a row). +- References ADR-098 (D4 CMMN semantics lead, D1 one engine, D2 the task + entity), ADR-065 (D6 concepts not notation, D8 no leaf-app engines), + ADR-031 (declarative-vs-imperative, design.md D-1), ADR-001 (seed data), + ADR-022 (the register owns business state), ADR-011 (reuse before + implement — `FlowExpression`). diff --git a/openspec/changes/flow-definition-versioning/.openspec.yaml b/openspec/changes/flow-definition-versioning/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/flow-definition-versioning/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/flow-definition-versioning/design.md b/openspec/changes/flow-definition-versioning/design.md new file mode 100644 index 0000000000..76fc04c3bc --- /dev/null +++ b/openspec/changes/flow-definition-versioning/design.md @@ -0,0 +1,299 @@ +# Design: flow-definition-versioning + +## Context + +See proposal.md — Why. The design-relevant state of the code today: + +- `openregister_flows` is one row per flow, created in + `lib/Migration/Version1Date20260803000000.php:67-126`. `nodes`, `edges` and + `limits` are JSON columns on that row; `uuid` is the flow's identity + everywhere else in the system. +- `FlowLocator::resolveFlow()` (`lib/Service/Flow/FlowLocator.php:88-115`) + turns a flow uuid into the plain document the engine lowers, memoised in + `$this->flowMemo[$flowId]` (lines 89-93) for the life of the request. +- `FlowRunAdvancer::advance()` calls that resolver on **every** pass + (`lib/Service/Flow/FlowRunAdvancer.php:92`) and fails the run when it comes + back null (`:98`). +- `FlowRunService::queue()` (`lib/Service/Flow/FlowRunService.php:321-352`) + is the single funnel every dispatch path goes through — its own docblock + says so — and calls `refuseDeadEnd()` first, which reads + `$flow->getNodes()` / `$flow->getEdges()` straight off the head row. +- `openregister_flow_triggers` + (`lib/Migration/Version1Date20260810140000.php:77-101`) denormalises + `flow_uuid`, `event`, `register`, `schema_slug`, `enabled` with the match + index `or_flowtrig_match_idx` deliberately covering the whole lookup so a + trigger match on an object write touches ONE table. +- `SubFlowNode::execute()` resolves its child through the same locator + (`lib/Service/Flow/Nodes/SubFlowNode.php:209`). +- Procest already runs this lifecycle for its workflow definitions: + `procest/lib/Service/Workflow/WorkflowLifecycleGuard.php:53-57` holds the + three constants and the preconditions; the enum and its prose live at + `procest/lib/Settings/register.d/70-cmmn-case-model.json:25-26`. + +## Goals / Non-Goals + +**Goals:** + +- A run resolves the same document from queue to termination, no matter how + long it waits in between. +- The flow uuid stays the flow's identity, so no app, trigger row, sub-flow + config or stored `flowId` has to be rewritten. +- Version resolution costs one indexed read and is memoisable, because the + advancer does it on every worker pass for every run. +- A missing pinned version is a visible failure with a specific message, not + a fallback. + +**Non-Goals:** + +- **In-flight instance migration.** Mapping a live token from version N's + node onto version N+1's node — Camunda's process-instance migration — is + out of scope, and this design deliberately makes it impossible rather than + half-possible. See proposal.md — What does NOT change. +- Semantic versioning of flows. The version is an ordinal, not a + compatibility statement; nothing computes "is version 4 compatible with + version 3". +- Diffing two versions in the UI. The editor gains a version selector and a + read-only view, not a graph diff. +- Branching or merging drafts. One draft at a time per flow. + +## Decisions + +### Decision 1: the head row keeps the identity; published versions are snapshot rows + +Two shapes were considered. + +**A — a row per version in `openregister_flows`.** This is procest's shape +(a definition row per version, pointing at its caseType). Rejected here: OR's +flow `uuid` is load-bearing OUTSIDE the table. `openregister_flow_triggers` +stores `flow_uuid`; `openregister_flow_runs` stores `flow_id` as a uuid; +`SubFlowNode`'s config names a flow by uuid; `FlowMapper::findByUuid()` is +assumed single-valued by `FlowLocator` and by `refuseDeadEnd()`. Making the +uuid non-unique would mean introducing a second "lineage" identifier and +rewriting every one of those call sites and their stored data — a migration +of other apps' configuration to express something they never asked about. + +**B — head row plus a snapshot table (chosen).** `openregister_flows` stays +one row per flow and gains `version` + `lifecycle_status`. Its `nodes` / +`edges` / `limits` are the **editable working copy**. A new table +`openregister_flow_versions` holds the immutable snapshot of each published +version: + +| column | why | +|---|---| +| `flow_uuid`, `version` | the pin, unique together | +| `status` | `published` \| `deprecated` — a draft has no snapshot row | +| `nodes`, `edges`, `limits`, `execution_mode` | exactly what `resolveFlow()` returns | +| `owner`, `organisation` | frozen with the graph: a run executes as the identity the version was published with | +| `published_at`, `published_by`, `deprecated_at` | the audit answer to "who changed the process" | + +Invariant, enforced in one transaction: at most one `published` row per +`flow_uuid`. A draft never gets a row, so the table only ever grows by an +act of publication. + +The cost of B is one denormalisation: a published head's `nodes` equals its +snapshot's `nodes`. That is accepted because it keeps every existing read +path working unchanged, and because a repair check can assert the equality +cheaply. + +`owner` being frozen onto the version is a real decision, not a copy: a run +pinned to version 2 executes as version 2's owner. Reading `owner` from the +head would let re-assigning a flow's owner silently change the identity a +two-week-old suspended run resumes as — the same class of bug as the graph +moving underneath it. + +### Decision 2: pin in `queue()`, resolve by (flow, version) in the locator + +`FlowRunService::queue()` is the only place every dispatch path passes +through, so it is the only place pinning has to be written. It resolves the +flow's `published` version, writes `flow_version` onto the run, and hands the +resolved version — not the flow uuid — to `refuseDeadEnd()`, which today +would preflight the editable head and therefore judge a draft when deciding +whether a published version may run. + +`FlowLocator::resolveFlow(string $flowId, ?int $version)` gains the version +argument, and its memo key becomes `"{$flowId}#{$version}"`. This is not +cosmetic: the worker advances a batch of runs in one process, and with the +current single-key memo (`FlowLocator.php:89-93`) the first run in the batch +would populate the cache and every later run of the same flow — including +one pinned to a different version — would silently read it. The memo is the +one place where "resolve by version" could look correct and behave wrongly. + +`$version === null` means "the head, unversioned" and exists for exactly two +callers: the interactive test run of a draft, and the editor's own preview. +It is never reachable from a queued run once the migration has back-filled, +which is asserted by a test rather than by convention. + +### Decision 3: a missing version fails with its own message, and never falls back + +`FlowRunAdvancer.php:98` already fails a run whose flow cannot be resolved, +with `No app provides flow "%s" (deleted, or its app removed?)`. Versioning +adds a second, distinct reason — the flow exists but the pinned version does +not — because the two have different operator responses: the first means an +app was removed, the second means somebody deleted history. + +The tempting alternative is a fallback to the latest published version, so +"the run keeps going". It is rejected outright: the run's marking names +places from the pinned graph, and its log records decisions taken against the +pinned graph. Re-pointing it produces a run that is internally inconsistent +and reports success. A loud failure is recoverable — an operator can requeue +against the current version, which is the existing retry semantics (retry +queues a NEW run). A silent promotion is not. + +### Decision 4: the trigger index stays version-free + +The alternative — adding `version` to `openregister_flow_triggers` — would +put a second dimension on the index that every object write pays for, to +answer a question the queue path answers one step later anyway. Instead: +only the published version contributes rows, publishing rebuilds that flow's +rows from the version being published, and deprecating the last published +version deletes them. `or_flowtrig_match_idx` and the "trigger matching MUST +NOT scale with the number of flows" requirement in +`openspec/specs/flow-engine/spec.md:427` are untouched — matching still +answers "which flow", and `queue()` answers "which version". + +This also makes the draft rule fall out for free rather than needing a +filter: a draft's trigger nodes are not in the index, so they cannot match. + +### Decision 5: a sub-flow pins its child at call time + +Three options: inherit the parent's version number (nonsense — versions are +per-flow), resolve the child's version when the PARENT was queued (pins a +child on a branch that may never be taken, and pins it staler the longer the +parent waits), or resolve the child's published version when the step +actually executes (chosen). + +Chosen because it is the same rule as everywhere else: a run is pinned when +it is queued, and a sub-flow call IS the child's queue moment for both +shapes — waiting and fire-and-forget. It also keeps `SubFlowNode`'s existing +property that a sub-flow is a call to whatever that flow currently is, which +is what makes a shared utility flow shareable across apps. + +A child with no published version fails the step naming the flow and its +state, rather than falling through to the draft. Falling through would make +an author's unfinished edit executable from someone else's flow. + +### Decision 6: an interactive test run of a draft carries its own snapshot + +Authors must be able to try a draft — `POST /api/flow-runs/test` +(`appinfo/routes.php:1314`) exists for exactly that. But a draft has no +snapshot row to pin to, and writing one would put unpublished graphs into the +version lineage and into its uniqueness rules. + +So a test run of a draft carries the resolved draft document on the RUN +itself (a `definitionSnapshot` entry in the run context), and the advancer +prefers that snapshot when present. The run is pinned in the same sense every +other run is — it cannot drift — without a version row existing. Test runs +are the only runs that ever carry an inline snapshot, which bounds the cost +and makes them identifiable in listings. + +## Declarative-vs-imperative decision (ADR-031) + +ADR-031's default path is declarative: a lifecycle belongs in +`x-openregister-lifecycle` on a schema in the register, executed by +`lib/Service/Lifecycle/TransitionEngine.php`, not in a new Service class. +This change takes the imperative path, and the reason is structural rather +than preferential. + +**The declarative dialect operates on register objects. A flow is not one.** +`lib/Db/Flow.php:5-11` states the position explicitly: the flow definition is +deliberately NOT an OpenRegister object, because definitions used to live in +a register/schema and that meant every app owning flows needed its own +register, its own resolver and its own executor. `openregister_flows` is a +native table reached by mapper, not by `ObjectService`. `TransitionEngine` +has no object to transition here, `x-openregister-lifecycle` has no schema to +hang on, and the transition `inputs` contract +(`lib/Service/Lifecycle/TransitionEngine.php:675-704`) has no register write +to validate against. Making the lifecycle declarative would mean moving flow +definitions back into a register — undoing `flow-engine-unification`, the +change that consolidated them. + +**So the guard is a service, and it mirrors one that already exists.** The +transitions (write the version row, deprecate the predecessor, rebuild the +trigger set) live in a `FlowVersionService`; the preconditions live in a +guard beside it, in the same split procest uses — +`WorkflowLifecycleGuard.php` owns "may this be published / deprecated", the +service owns "do it". Worth noting that procest's `workflowTemplate` IS a +register object and its lifecycle is still imperative, because its +preconditions are referential (every status a definition references must +belong to its own caseType) and the dialect does not express cross-object +referential checks. The flow equivalents — "the version being published has +no dead end", "no non-terminal run is pinned to the version being removed" — +are the same shape. + +**What stays declarative:** nothing about flow versioning is expressible in +the dialect, so nothing is being taken away from it. In particular this +change adds no notification, aggregation, calculation or relation — if +"a version was published" later needs to notify, it notifies through the +ADR-031 subsystem from the same place every other flow-side send does +(`flow-messaging-nodes`), not through a second mechanism. + +**No seed data.** No register or schema is introduced or modified, so +ADR-001 seed data does not apply. The equivalent obligation here is the +back-fill in the migration plan below, which is a data repair with an +idempotency requirement rather than seeded content. + +## Risks / Trade-offs + +- **The version table grows one row per publication, forever.** → Accepted: + publications are human-rate events, and the rows are the audit trail of who + changed a process. A retention policy for versions with no non-terminal + runs and no history value is a later change, and it MUST NOT delete a + version any run is pinned to. +- **Head/snapshot denormalisation can drift** if a code path writes the head + while it is published. → Mitigated by the refusal at the API boundary, by + the guard refusing to publish a head that is not a draft, and by a repair + check asserting head-equals-snapshot for a published flow. +- **The upgrade window.** A run queued between the schema migration and the + back-fill would have a null pin. → The back-fill runs in the same migration + step as the column, and null-pin runs are treated as "resolve the head" + exactly as today, so the worst case during the window is today's behaviour, + not a failure. +- **Authors lose "just tweak the live flow".** Editing now costs an explicit + create-draft and publish. → That is the point, and it is why the editor + affordance is part of this change rather than a follow-up: the refusal must + be visible before the edit, not on save. +- **A long-suspended run holds its version alive**, so an operator cannot + fully retire a process while an approval from three weeks ago is still + parked. → Deliberate — that is what `deprecated` means. The active-runs + surface makes those runs findable so they can be stopped explicitly. +- **`enabled` and `lifecycle_status` are two flags an operator can confuse.** + → Mitigated by the UI showing both with distinct language, and by the + queue-time refusal naming which of the two stopped a run. + +## Migration Plan + +1. **Schema.** Add `version` (integer, notnull, default `1`) and + `lifecycle_status` (string 16, notnull, default `draft`) to + `openregister_flows`; add `flow_version` (integer, nullable) to + `openregister_flow_runs`; create `openregister_flow_versions` with a + unique index on `(flow_uuid, version)` and an index on + `(flow_uuid, status)`. +2. **Back-fill.** For every existing flow, insert a `published` version 1 row + from its current `nodes`/`edges`/`limits`/`execution_mode`/`owner`/ + `organisation`, and set the head's `lifecycle_status` to `published`. + Every existing flow is treated as published because it is already live and + already triggering; marking them drafts would stop the instance. +3. **Pin the in-flight.** Set `flow_version = 1` on every run not in a + terminal state. Terminal runs are left null — pinning a finished run + states nothing true about how it ran. +4. **Idempotency.** Both steps are guarded on existence, so re-running the + migration creates no second version 1 and re-pins no run. +5. **Rollback.** The columns and table are additive and nothing reads + `flow_version` when it is null, so reverting the app code restores exactly + today's behaviour with the new columns inert. Dropping them is a separate, + optional migration and MUST NOT be part of the rollback path — dropping a + column that a re-deploy will need back is how a rollback becomes an + outage. +6. **Verification.** After deploy: every flow has exactly one `published` + version; no non-terminal run has a null `flow_version`; no run is pinned + to a version that does not exist. + +## Open Questions + +- Whether a version retention policy is needed at all, and on what horizon. + Deferrable: it changes neither the specs nor the tasks here, and it cannot + be designed before there is a fleet's worth of publication data to look at. +- Whether the editor should offer "copy version N into a new draft" as well + as "create draft from the published version". Deferrable: it is one extra + source for the same create-draft transition, and adding it later changes no + stored shape. diff --git a/openspec/changes/flow-definition-versioning/proposal.md b/openspec/changes/flow-definition-versioning/proposal.md new file mode 100644 index 0000000000..663680a846 --- /dev/null +++ b/openspec/changes/flow-definition-versioning/proposal.md @@ -0,0 +1,175 @@ +--- +kind: code +--- + +# Proposal: flow-definition-versioning + +## Summary + +Give a flow definition a **version** and a lifecycle (`draft` / `published` / +`deprecated`), pin the version onto the run at queue time, and make the +advancer resolve THAT version for the whole life of the run. A run stops +being a walk over whatever the graph happens to look like when the worker +next wakes up, and becomes a walk over the definition it started on. + +## Why + +`FlowRunAdvancer::advance()` re-resolves the live definition on **every** +worker pass: + +```php +// lib/Service/Flow/FlowRunAdvancer.php:92 +$flow = $this->resolvers->resolveFlow((string)$run->getFlowId()); +``` + +`FlowLocator::resolveFlow()` (`lib/Service/Flow/FlowLocator.php:88-115`) +answers from `FlowMapper::findByUuid()` — the single current row in +`openregister_flows`. There is no version column: the table +(`lib/Migration/Version1Date20260803000000.php:67-126`) has `nodes`, +`edges`, `limits` and nothing that says *which* nodes and edges. So the +definition a run executes is a moving target, and the engine has no way to +notice it moved. + +That is tolerable while runs are short. It stops being tolerable the moment +a run can wait for a person. `openregister.await-signal` suspends with +`resumeAt = null` and the run is only reaped after **days** +(`lib/Cron/FlowRunWorker.php`); a run parked two weeks on an approval wakes +against a graph an author has edited three times since. Two concrete +failures follow: + +1. **The marking dangles.** `FlowGraph::inPlace()` makes a node's input + place its node id, so the run's persisted `marking` is a set of node ids. + Rename or delete a node while a token sits in it and the marking now + names a place the rebuilt Petri net does not contain. The run cannot be + advanced and cannot explain why. +2. **The process silently changes under a decision already taken.** An + approver answered a question the flow no longer asks. Nothing in the run + log records that the definition changed mid-run, because nothing knows. + +Every other change in the ADR-098 programme makes this worse rather than +better: human tasks, business timers and approval chains all lengthen the +window between "queued" and "finished". Pinning is therefore the hard +prerequisite, and it is ADR-098 Decision 6 — *versioning before humans*. + +The lifecycle is not invented here. Procest already runs it in production +for workflow definitions: `lifecycleStatus` with enum +`["draft","published","deprecated"]` +(`procest/lib/Settings/register.d/70-cmmn-case-model.json:26` — "draft = +editable, cannot back new cases. published = immutable, can back new cases +(one active per caseType). deprecated = immutable, existing cases keep using +it"), with the preconditions in +`procest/lib/Service/Workflow/WorkflowLifecycleGuard.php:53-57` and the +transitions in `WorkflowDefinitionService`. This change moves that proven +shape onto OR Flow, which is where ADR-098 says the fleet's one engine +lives. + +## What Changes + +- **A flow row gets a lifecycle.** `openregister_flows` gains `version` + (integer, the head's number) and `lifecycle_status` + (`draft|published|deprecated`, default `draft`). The row keeps being the + flow's identity: its `uuid` does not move, so `FlowMapper::findByUuid()`, + `openregister_flow_triggers.flow_uuid` and every `flowId` an app has + stored keep resolving. +- **Published definitions become immutable snapshots.** A new table + `openregister_flow_versions` holds one row per published version + (`flow_uuid`, `version`, `status`, `nodes`, `edges`, `limits`, + `execution_mode`, `owner`, `organisation`, `published_at`, + `published_by`). At most **one** `published` row per flow; publishing + version N+1 deprecates N in the same transaction. +- **The run pins its definition.** `openregister_flow_runs` gains + `flow_version`. `FlowRunService::queue()` + (`lib/Service/Flow/FlowRunService.php:321`) resolves the version that will + back the run and writes it onto the record — every dispatch path (manual, + object trigger, schedule, MCP, workflow-engine operation, sub-flow) already + funnels through that one method, so pinning is applied once and covers all + of them. +- **The advancer resolves the pinned version.** + `FlowLocator::resolveFlow()` gains a version argument, and its memo + (`FlowLocator.php:89-93`, keyed by `flowId` alone today) is re-keyed by + `flowId + version` — otherwise two runs of the same flow on different + versions in one worker batch would read each other's graph. + `FlowRunAdvancer.php:92` passes `$run->getFlowVersion()`. +- **A missing pinned version fails loudly.** The existing "No app provides + flow" refusal (`FlowRunAdvancer.php:98`) is joined by a distinct one that + names the flow *and* the version. The engine MUST NOT re-point a run at + another version — silently promoting a run to a newer graph is precisely + the bug this change removes. +- **Editing a published flow is refused, not merged.** `PUT /api/flows/{id}` + (`appinfo/routes.php:539`) returns a machine-readable 409 when the head is + `published`. The author's path is explicit: create a draft (version N+1), + edit it, publish it. New routes: create-draft, publish, deprecate, list + versions, read one version. +- **The editor shows which version it is looking at.** A published flow's + canvas (`src/views/flows/FlowDetailPage.vue`) renders read-only with a + "Create draft version" action; the sidebar + (`src/views/flows/FlowDetailSidebar.vue`) carries the version selector and + lifecycle badge; a run's detail shows the version it is pinned to. +- **Existing flows and in-flight runs are back-filled**, so the upgrade does + not strand anything — see Impact. + +## What does NOT change + +- **In-flight instance MIGRATION is explicitly out of scope.** Camunda's + activity-mapping ("move the token from old node A to new node B, then + continue on version N+1") is a different feature with a different risk + profile, and it needs a mapping UI, a validation pass and an audit story of + its own. This change makes pinning MANDATORY and migration IMPOSSIBLE: a + run finishes on the version it started on, or it fails visibly. Migration + is a later change, and it is buildable only once pinning exists. +- **The trigger index stays version-free.** `openregister_flow_triggers` + (`lib/Migration/Version1Date20260810140000.php:77-101`) keeps its + `(enabled, event, register, schema_slug)` match index and its `flow_uuid` + column. Only the published version contributes rows, so a match answers + "which flow", and `queue()` answers "which version" one step later. Adding + a version column would put a second dimension on the hot path that every + object write pays for, to express something the queue path already knows. +- **The Petri-net lowering, merge/join semantics, oversight and the + `MAX_TRANSITIONS` backstop.** Versioning changes which document is lowered, + not how. +- **`enabled`.** It stays orthogonal: a published flow can be switched off, + and a disabled flow is not a deprecated one. + +## Capabilities + +### New Capabilities +- `flow-definition-versioning`: versioned flow definitions with a + `draft`/`published`/`deprecated` lifecycle, per-run version pinning at + queue time, version-faithful resolution for the life of a run, and loud + failure when a pinned version is gone. + +### Modified Capabilities + + +## Impact + +- **Affected code**: `lib/Db/Flow.php`, `lib/Db/FlowRun.php`, new + `lib/Db/FlowVersion.php` + mapper; `lib/Service/Flow/FlowLocator.php` + (version-aware resolve + memo key); `lib/Service/Flow/FlowRunAdvancer.php` + (pinned resolve, missing-version refusal); + `lib/Service/Flow/FlowRunService.php` (`queue()` pins; `refuseDeadEnd()` + must preflight the version being pinned, not the head); + `lib/Service/Flow/Nodes/SubFlowNode.php:209` (a sub-flow call resolves the + child's published version at CALL time and pins it on the child run); + `lib/Controller/FlowController.php` + `appinfo/routes.php`; new + `FlowVersionService` and lifecycle guard. +- **Affected data**: two migrations — columns on `openregister_flows` and + `openregister_flow_runs`, plus the new `openregister_flow_versions` table. + A repair step publishes version 1 of every existing flow from its current + graph and stamps `flow_version = 1` on every non-terminal run, so nothing + in flight at upgrade time is left with an unresolvable pin. +- **Affected apps**: every consumer of the shared engine gains the lifecycle; + no app-side change is required to keep working, because the head row and + its uuid are unmoved. Apps that ship flow definitions (hermiq, procest, + openconnector under ADR-098 Decision 1) publish version 1 on install. +- **Affected UI**: `src/views/flows/FlowDetailPage.vue`, + `FlowDetailSidebar.vue`, `FlowsIndex.vue`. +- **ADRs**: ADR-098 Decision 6 (this change is that decision), ADR-065 (one + engine — the lifecycle lands in the engine, not per app), ADR-031 + (declarative-first: why the guard is imperative here is argued in + design.md). +- **Blocks**: `flow-task-entity` and `flow-parallel-streams` both declare + `depends_on: flow-definition-versioning`; the rest of the ADR-098 chain + sits behind those. diff --git a/openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md b/openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md new file mode 100644 index 0000000000..7581c8aa63 --- /dev/null +++ b/openspec/changes/flow-definition-versioning/specs/flow-definition-versioning/spec.md @@ -0,0 +1,326 @@ +## Purpose + +Gives a flow definition a version and a `draft`/`published`/`deprecated` +lifecycle, and binds every run to the exact version it started on, so a run +that waits days for a person still finishes on the process it began. + +## ADDED Requirements + +### Requirement: A flow definition carries a version and a lifecycle status + +A flow SHALL carry a `version` (a positive integer) and a `lifecycleStatus` +of exactly `draft`, `published` or `deprecated`. The meaning of each state +SHALL be: + +- `draft` — editable; MUST NOT back a new triggered, scheduled or sub-flow + run. +- `published` — immutable; backs new runs. A flow SHALL have **at most one** + `published` version at any time. +- `deprecated` — immutable; MUST NOT back a new run, and runs already pinned + to it SHALL continue and finish on it. + +The flow's identity SHALL NOT move when its version does: the flow uuid that +apps, trigger records and stored `flowId` configuration refer to SHALL keep +addressing the same flow across every version of it. + +`lifecycleStatus` SHALL be independent of the flow's `enabled` flag. A +published flow may be switched off, and a switched-off flow is not a +deprecated one. + +#### Scenario: A new flow starts as a draft + +- **GIVEN** an author creates a flow +- **WHEN** the flow is first saved +- **THEN** its lifecycle status MUST be `draft` and its version MUST be `1` +- **AND** no published version MUST exist for it +- @e2e exclude persistence contract — covered by flow lifecycle unit tests + +#### Scenario: Publishing a new version deprecates its predecessor + +- **GIVEN** a flow whose version 2 is `published` and whose version 3 is a + `draft` +- **WHEN** version 3 is published +- **THEN** version 3 MUST become `published` and version 2 MUST become + `deprecated` in the same transaction +- **AND** the flow MUST still have exactly one `published` version +- @e2e exclude transactional lifecycle rule — covered by lifecycle guard unit + tests + +#### Scenario: A published flow can be disabled without being deprecated + +- **GIVEN** a flow with a published version +- **WHEN** its `enabled` flag is set to false +- **THEN** the published version MUST remain `published` +- **AND** new triggers MUST NOT queue runs, for the reason "disabled", not + "deprecated" +- @e2e exclude flag orthogonality — covered by unit tests + +### Requirement: A published version is immutable, and editing produces a new draft + +Once published, a version's graph — its nodes, edges and limits — SHALL NOT +change. An attempt to write a definition change onto a flow whose head is +`published` SHALL be REFUSED with a client error carrying a +machine-readable reason, and SHALL NOT be silently applied, merged, or +turned into a new version behind the author's back. + +Creating a draft from a published flow SHALL copy the published graph into a +new draft at version N+1 and leave version N published and backing new runs +until the draft is itself published. + +Deleting or discarding a draft SHALL NOT affect any published or deprecated +version, and SHALL NOT affect any run. + +#### Scenario: Editing a published flow is refused + +- **GIVEN** a flow whose head version is `published` +- **WHEN** a client submits a changed set of nodes or edges for it +- **THEN** the request MUST be refused with a 409 and a machine-readable + reason naming the lifecycle state +- **AND** the stored graph MUST be byte-identical to what it was before the + request +- @e2e exclude API contract — covered by controller tests and Newman + +#### Scenario: Creating a draft leaves the published version serving + +- **GIVEN** a flow with published version 4 and a trigger wired to it +- **WHEN** the author creates a draft version 5 and edits it +- **THEN** version 4 MUST remain `published` +- **AND** runs queued by that trigger while version 5 is still a draft MUST + be pinned to version 4 +- @e2e exclude lifecycle + pinning interaction — covered by integration tests + +### Requirement: A run is pinned to a definition version when it is queued + +Every run SHALL record the flow version that backs it at the moment it is +queued, and that recorded version SHALL NOT change for the life of the run. +Pinning SHALL apply to every dispatch path without exception — manual, +object trigger, schedule, MCP, workflow-engine operation and sub-flow call. + +A run SHALL be queued against the flow's `published` version. A `draft` or +`deprecated` version SHALL NOT back a newly queued run. + +The pre-queue refusal that rejects a flow with a node its token cannot leave +SHALL inspect the version being pinned, not the flow's editable head — a +draft's dead end is not grounds to refuse a run of the published version, +and a published version's dead end MUST NOT be hidden by a repaired draft. + +An interactive test run is the one exception to "a draft cannot back a run": +it SHALL be permitted against a draft, and SHALL carry the exact draft graph +it was started with on the run itself, so it is pinned in the same sense +every other run is. A test run of a draft SHALL be distinguishable from a run +of a published version wherever runs are listed. + +#### Scenario: A queued run records its version + +- **GIVEN** a flow with published version 3 +- **WHEN** an object trigger queues a run +- **THEN** the run record MUST carry version 3 +- @e2e exclude persistence contract — covered by queue-path unit tests + +#### Scenario: Publishing a new version does not move a queued run + +- **GIVEN** a run queued against published version 3 and not yet started +- **WHEN** version 4 is published before the worker reaches that run +- **THEN** the run MUST still be pinned to version 3 and MUST execute + version 3's graph +- @e2e exclude engine-internal pinning — covered by advancer unit tests + +#### Scenario: The dead-end refusal judges the pinned version + +- **GIVEN** a flow whose published version 2 is fully wired and whose draft + version 3 has a node with no outgoing edge +- **WHEN** a trigger queues a run +- **THEN** the run MUST be accepted and pinned to version 2 +- @e2e exclude preflight scoping — covered by dead-end unit tests + +### Requirement: A run advances against its pinned version, never the live definition + +Each time a run is advanced, the engine SHALL resolve the definition by +flow AND pinned version, and SHALL lower and walk that document. It SHALL +NOT read the flow's current head, and SHALL NOT substitute a newer version +for the pinned one under any circumstance — including a run resumed after an +arbitrarily long suspension. + +Definition resolution caching SHALL be keyed by flow AND version. Two runs of +the same flow pinned to different versions, advanced in the same worker +batch, SHALL each receive their own version's graph. + +The run's persisted marking SHALL therefore always name places that exist in +the document being walked, for as long as the pinned version exists. + +#### Scenario: A run suspended across an edit resumes on its own version + +- **GIVEN** a run pinned to version 1, suspended on an external signal +- **AND** version 2 has since been published with a node renamed +- **WHEN** the signal arrives fourteen days later and the run is advanced +- **THEN** the run MUST resume against version 1 +- **AND** its marking MUST resolve without a dangling place +- @e2e exclude long-suspension behaviour — covered by advancer integration + tests with a clock double + +#### Scenario: Two versions of one flow advance in the same batch + +- **GIVEN** one run pinned to version 1 and another pinned to version 2 of + the same flow +- **WHEN** a single worker pass advances both +- **THEN** each MUST execute the graph of its own version +- **AND** neither MUST observe the other's nodes or edges +- @e2e exclude resolver cache keying — covered by locator unit tests + +### Requirement: A run whose pinned version is gone fails loudly and is never re-pointed + +When a run's pinned version cannot be resolved — the flow was deleted, the +owning app was removed, or the version row is absent — the run SHALL be +failed with an error that names BOTH the flow and the version that could not +be found. That error SHALL be distinguishable from the existing "no app +provides this flow" case, so an operator can tell "the flow is gone" from +"this version of it is gone". + +The engine SHALL NOT fall back to the flow's head, to the latest published +version, or to any other version. Silently promoting an in-flight run onto a +different definition is forbidden, because the run's marking, its taken +decisions and its log all belong to the version it started on. + +The run SHALL NOT be left queued, so a run whose definition disappeared can +never sit in the queue indefinitely being retried. + +#### Scenario: The pinned version was deleted + +- **GIVEN** a suspended run pinned to version 2, and version 2 has been + removed +- **WHEN** the worker next advances it +- **THEN** the run MUST end in a failed state +- **AND** its error MUST name both the flow and version 2 +- **AND** it MUST NOT have executed any node of any other version +- @e2e exclude failure path — covered by advancer unit tests + +#### Scenario: A newer version is not a substitute + +- **GIVEN** a run pinned to a version that no longer resolves, while a newer + published version of the same flow exists +- **WHEN** the run is advanced +- **THEN** the run MUST fail +- **AND** it MUST NOT be re-pinned to the newer version +- @e2e exclude no-fallback rule — covered by advancer unit tests + +### Requirement: A sub-flow call pins the child run at call time + +When a step runs another flow, the child SHALL be pinned to the child flow's +own `published` version resolved at the moment the step executes — not +inherited from the parent's version number, and not resolved when the parent +was queued. + +This SHALL hold for both sub-flow shapes: the waiting call, whose child run +executes within the parent's step, and the fire-and-forget call, whose child +run is queued. + +A sub-flow step whose named flow has no `published` version SHALL fail the +step with a reason naming the flow and its lifecycle state, and SHALL NOT +fall back to that flow's draft. + +#### Scenario: The child pins its own published version + +- **GIVEN** parent flow P pinned to version 1, calling child flow C +- **AND** C's published version is 7 +- **WHEN** the sub-flow step executes +- **THEN** the child run MUST be pinned to C version 7 +- @e2e exclude sub-flow pinning — covered by sub-flow node unit tests + +#### Scenario: A child with only a draft refuses the call + +- **GIVEN** a sub-flow step naming a flow that has never been published +- **WHEN** the step executes +- **THEN** the step MUST fail with a reason naming the flow and its draft + state +- **AND** the draft MUST NOT have been executed +- @e2e exclude sub-flow refusal — covered by sub-flow node unit tests + +### Requirement: Trigger matching answers which flow; the queue path answers which version + +The trigger index SHALL remain keyed by flow, event, register and schema, +without a version dimension, so the cost of matching a trigger on an object +write does not grow with the number of versions a flow has. + +Only a flow's `published` version SHALL contribute trigger records. +Publishing a version SHALL rebuild that flow's trigger records from the +version being published; deprecating the last published version SHALL remove +them. A draft's trigger nodes SHALL NOT match anything. + +#### Scenario: A draft's new trigger does not fire + +- **GIVEN** published version 1 with an object-created trigger, and draft + version 2 that adds an object-updated trigger +- **WHEN** an object of that schema is updated +- **THEN** no run MUST be queued +- @e2e exclude trigger index scoping — covered by trigger service unit tests + +#### Scenario: Publishing swaps the trigger set atomically + +- **GIVEN** published version 1 triggering on schema A and draft version 2 + triggering on schema B +- **WHEN** version 2 is published +- **THEN** writes to schema B MUST queue runs pinned to version 2 +- **AND** writes to schema A MUST NOT queue runs +- @e2e exclude trigger rebuild — covered by integration tests + +### Requirement: The upgrade leaves nothing in flight unresolvable + +The migration that introduces versioning SHALL publish version 1 of every +existing flow from that flow's current stored graph, and SHALL pin +`version 1` onto every run that is not in a terminal state at upgrade time. + +No run that was queued or suspended before the upgrade SHALL be left with an +unresolvable pin, and none SHALL be failed by the upgrade itself. + +The migration SHALL be idempotent: running it twice SHALL NOT create a second +version 1, and SHALL NOT re-pin a run that is already pinned. + +#### Scenario: A suspended pre-upgrade run keeps running + +- **GIVEN** a run suspended on a signal before the upgrade, with no version + recorded +- **WHEN** the migration runs and the signal then arrives +- **THEN** the run MUST be pinned to version 1 +- **AND** it MUST advance against the graph that was live at upgrade time +- @e2e exclude migration behaviour — covered by migration tests against a + seeded database + +#### Scenario: Re-running the migration changes nothing + +- **GIVEN** a database already migrated +- **WHEN** the migration is applied again +- **THEN** the version rows and run pins MUST be unchanged +- @e2e exclude idempotency — covered by migration tests + +### Requirement: The editor states which version it is showing and why it is read-only + +The flow editing surface SHALL show, for the flow being viewed, its version +number and its lifecycle status, and SHALL make a published or deprecated +version non-editable in the interface rather than letting an author edit it +and discover the refusal on save. + +A published flow SHALL offer an explicit action to create a draft version. +An author SHALL be able to list a flow's versions and open any one of them +read-only. + +A run's detail SHALL show the version it is pinned to, and SHALL mark a run +pinned to a deprecated version as such, so "why is this run behaving +differently from the flow I am looking at" is answerable without reading the +database. + +#### Scenario: A published flow's canvas is read-only + +- **GIVEN** an author opens a flow whose head version is published +- **THEN** the canvas MUST NOT accept node or edge edits +- **AND** a "create draft version" action MUST be offered +- **AND** the version number and lifecycle status MUST be visible +- @e2e covered by the flow editor lifecycle e2e spec + +#### Scenario: A run shows its pinned version + +- **GIVEN** a run pinned to version 2 of a flow whose head is version 4 +- **WHEN** the run detail is opened +- **THEN** version 2 MUST be shown as the run's definition +- **AND** the run MUST be marked as running a version that is no longer the + published one +- @e2e covered by the flow run detail e2e spec diff --git a/openspec/changes/flow-definition-versioning/tasks.md b/openspec/changes/flow-definition-versioning/tasks.md new file mode 100644 index 0000000000..ac1bae5fc4 --- /dev/null +++ b/openspec/changes/flow-definition-versioning/tasks.md @@ -0,0 +1,126 @@ +# Tasks: flow-definition-versioning + +## 1. Storage + +- [ ] 1.1 Migration: `version` (int, notnull, default 1) + `lifecycle_status` + (string 16, notnull, default `draft`) on `openregister_flows`; + `flow_version` (int, nullable) on `openregister_flow_runs`; new + `openregister_flow_versions` (`flow_uuid`, `version`, `status`, `nodes`, + `edges`, `limits`, `execution_mode`, `owner`, `organisation`, + `published_at`, `published_by`, `deprecated_at`) with a UNIQUE index on + `(flow_uuid, version)` and an index on `(flow_uuid, status)`. +- [ ] 1.2 `lib/Db/FlowVersion.php` + `FlowVersionMapper` (find by flow+version, + find the single published version, list a flow's versions); `Flow` and + `FlowRun` entities extended with the new fields and their accessors. +- [ ] 1.3 Repair step in the same migration: publish version 1 of every + existing flow from its stored graph, set its head to `published`, and + stamp `flow_version = 1` on every non-terminal run. Guarded on existence + so a second run changes nothing. + +## 2. Lifecycle + +- [ ] 2.1 `FlowLifecycleGuard` — the preconditions, modelled on + `procest/lib/Service/Workflow/WorkflowLifecycleGuard.php:53-57`: only a + draft may be published, only after the dead-end preflight passes on the + graph being published; only a published version may be deprecated; a + version with a non-terminal run pinned to it may not be deleted. Every + refusal logged with its reason. +- [ ] 2.2 `FlowVersionService` — the transitions: publish (snapshot the head, + deprecate the predecessor, rebuild the trigger set) and create-draft + (copy the published graph to version N+1, head back to `draft`), each in + ONE transaction so a flow is never observed with two published versions + or none. +- [ ] 2.3 Trigger-set rebuild wired to publish/deprecate only: + `openregister_flow_triggers` rows are derived from the published version + and from nothing else; the table keeps its columns and + `or_flowtrig_match_idx` unchanged. + +## 3. Pinning and resolution + +- [ ] 3.1 `FlowRunService::queue()` (`lib/Service/Flow/FlowRunService.php:321`) + resolves the published version, writes it onto the run, and refuses with + a named reason when there is none. All six dispatch paths inherit it — + assert that with a test per path rather than by reading the code. +- [ ] 3.2 `refuseDeadEnd()` preflights the version being pinned, not + `$flow->getNodes()` off the head, so a broken draft cannot refuse a run + of a sound published version and a broken published version cannot be + masked by a repaired draft. +- [ ] 3.3 `FlowLocator::resolveFlow()` takes a version; memo key becomes + flow + version (`FlowLocator.php:89-93` is keyed by flow alone today and + would serve one run's graph to another in the same worker batch). +- [ ] 3.4 `FlowRunAdvancer.php:92` resolves the run's pinned version, and `:98` + gains a second, distinct refusal naming flow AND version. No fallback to + head or to the latest published version on any path. +- [ ] 3.5 Interactive test run of a draft carries the resolved draft document + on the run context; the advancer prefers that snapshot when present. + Test runs are the only runs that carry one, and are marked as such where + runs are listed. + +## 4. Callers + +- [ ] 4.1 `SubFlowNode::execute()` (`lib/Service/Flow/Nodes/SubFlowNode.php:209`) + resolves the CHILD flow's published version at call time and pins it on + the child run, for both the waiting and fire-and-forget shapes; a child + with no published version fails the step naming the flow and its state. + Apps that SHIP flow definitions (hermiq, procest, openconnector under + ADR-098 Decision 1) publish version 1 on install, so a shipped flow is + never delivered as an unrunnable draft. + +## 5. API and editor + +- [ ] 5.1 `PUT /api/flows/{id}` (`appinfo/routes.php:539`) refuses a definition + write against a published head with a 409 carrying a machine-readable + reason; the stored graph is left untouched. +- [ ] 5.2 New routes and controller methods: create-draft, publish, deprecate, + list a flow's versions, read one version — each with its Nextcloud auth + attribute and each placed so no literal segment can be captured as a + flow uuid (the trap `appinfo/routes.php:529` already warns about). +- [ ] 5.3 Editor: `FlowDetailPage.vue` read-only for a published or deprecated + version with a "create draft version" action; `FlowDetailSidebar.vue` + shows version + lifecycle badge and the version list; run detail shows + the pinned version and marks a run on a deprecated version. + +## 6. Tests + +- [ ] 6.1 Pinning tests: a run pinned before a publish executes the old graph; + two runs on different versions advance correctly in one worker batch + (the memo-key regression); a suspended run resumes on its own version + after a rename that would have dangled its marking. +- [ ] 6.2 Failure tests: a deleted pinned version fails the run with the + version-specific message, never re-points it at a newer version, and + never leaves it queued. +- [ ] 6.3 Lifecycle, migration and regression: one published version per flow + through publish/deprecate cycles; a draft's trigger nodes match nothing; + migration back-fill over a seeded database with in-flight runs, applied + twice with identical results; and a pass with opencatalogi and + softwarecatalog installed proving their flows still resolve by uuid and + still run after the migration. + +## Acceptance criteria + +- No code path resolves a flow definition for a queued run without a version. + A grep for `resolveFlow(` returns only version-aware callers plus the two + documented head callers (draft test run, editor preview). +- A run's executed graph is a function of its `flow_version` alone. Publishing, + deprecating or deleting anything while a run is in flight changes what that + run does in exactly zero cases. +- A pinned version that cannot be resolved produces a failed run whose error + names the version. It never produces a completed run. +- The trigger match path still touches one table with one index; no version + column was added to `openregister_flow_triggers`. +- An author cannot edit a published flow anywhere — the API refuses it and the + editor does not offer it. +- After migration, every flow has exactly one published version and no + non-terminal run has a null `flow_version`. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- New PHP files carry `@license EUPL-1.2` and `@copyright 2026 Conduction B.V.` +- `@spec` annotations point at + `openspec/specs/flow-definition-versioning/spec.md` anchors. +- References ADR-098 Decision 6 (versioning before humans), ADR-065 (the + lifecycle lands in the one engine), ADR-031 (the imperative guard is argued + in design.md, not assumed). +- In-flight instance migration is NOT implemented here and no partial hook for + it is left behind. diff --git a/openspec/changes/flow-parallel-streams/.openspec.yaml b/openspec/changes/flow-parallel-streams/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/flow-parallel-streams/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/flow-parallel-streams/design.md b/openspec/changes/flow-parallel-streams/design.md new file mode 100644 index 0000000000..3832100415 --- /dev/null +++ b/openspec/changes/flow-parallel-streams/design.md @@ -0,0 +1,611 @@ +# Design: flow-parallel-streams + +## Context + +See proposal.md — Why. The design-relevant state of the code today: + +- **The walk is one loop.** `FlowEngine::run()` is a single `while (true)` + (`lib/Service/Flow/FlowEngine.php:310-546`). It asks + `getEnabledTransitions()` for the whole set (`:311`), narrows it to ONE with + `selectTransition()` (`:344`), dispatches (`:427`), then + `$workflow->apply()` (`:535`). Several enabled transitions are fired in + succession within one pass; none of them is ever in flight at the same time + as another. +- **Ending is decided by whoever notices first.** An empty enabled set returns + `STATUS_COMPLETED` (`FlowEngine.php:312-322`); a `FlowSuspension` returns + `STATUS_SUSPENDED` for the whole run (`:474-493`). +- **The marking is a whole-value write.** `FlowRunMarkingStore::setMarking()` + does `$this->run->setMarking($marking->getPlaces())` + (`lib/Service/Flow/FlowRunMarkingStore.php:102-105`) from a `Marking` object + built by `getMarking()` (`:67-86`) out of the run row read at the top of the + pass. Read-modify-write on one JSON column, with no version and no lock. +- **Places already encode the join.** `FlowGraph::inPlace()` returns the bare + node id (`lib/Service/Flow/FlowGraph.php:67-69`), and a declared join gets + one input place per incoming edge, `"{nodeId}#{edgeId}"` + (`FlowGraph.php:103-105`, reached from `targetPlace()` at `:85-91`). The + conflict structure this design needs is therefore already in the graph — no + new naming is required. +- **Items are per-place in memory and flat on disk.** `advanceItems()` moves + items onto `$transition->getTos()` and clears `getFroms()` + (`lib/Service/Flow/FlowItemPlacement.php:133-187`); `seedPlaceItems()` + rebuilds those buffers on resume by assigning the SAME list to every marked + place (`:90-103`), because the run persists one flat `items` array + (`lib/Service/Flow/FlowRunService.php:810`). The comment at + `FlowItemPlacement.php:134-161` records openregister#2488 — a shared output + place losing items with no trace in any log. +- **The step sequence is a read-then-insert.** `recordSteps()` takes + `highestSequence() + 1` (`FlowRunService.php:677`, mapper at + `lib/Db/FlowRunStepMapper.php:81-97`) and increments a local + (`FlowRunService.php:729`); `findByRun()` sorts `ORDER BY sequence ASC` + (`FlowRunStepMapper.php:62`). Table `openregister_flow_steps` + (`FlowRunStepMapper.php:44`). +- **Status is what every queue query filters on.** `findQueued()` + (`lib/Db/FlowRunMapper.php:643`), `findDue()` (`:503-507`, `suspended`), + `findStale()` (`:543-547`, `running`), `flowsWithQueuedRuns()` (`:689-694`) + and `touch()` (`:240-255`, whose `UPDATE ... WHERE status = running` guard is + the existing precedent for "only write this row in the state that makes the + write mean something"). +- **Recovery already has a cutoff and a posture.** `FlowRunWorker::reapStale()` + (`lib/Cron/FlowRunWorker.php:226-275`) computes + `max(flow_run_stale_minutes, flow_max_runtime_minutes + REAP_GRACE_MINUTES)` + (`:251-261`, constants at `:81`, `:105`, `:117`) and FAILS what it reaps + rather than requeueing it (`:263-272`, reasoning at `:207-218`). +- **Bounding already has numbers and an argument.** `FlowConcurrency`'s header + docblock (`lib/Service/Flow/FlowConcurrency.php:20-31`) names the three + properties a naive fan-out loses — BOUND, ORDER, ISOLATION — and its + constants are `DEFAULT_LIMIT = 5` (`:72`) and `MAX_LIMIT = 20` (`:83`), + clamped by `boundedLimit()` (`:188-194`). +- **Oversight is per-hop and fail-closed.** `assertOversightAllows()` runs + immediately before dispatch (`FlowEngine.php:425`) and + `FlowOversightRegistry::firstRefusal()` treats a check that throws as a + refusal (`lib/Service/Flow/FlowOversightRegistry.php:102-128`, the fail-closed + branch at `:106-120`). +- **An atomic-reservation pattern already exists in this app.** + `SequenceService::reserveNext()` (`lib/Service/SequenceService.php:76-115`) + wraps a conditional `UPDATE` (`lib/Db/SequenceMapper.php:84-93`) and a + unique-index-guarded seed in one `IDBConnection` transaction. That is the + shape both the claim and the marking delta reuse, so this change introduces + no concurrency primitive the codebase has not already shipped. + +## Goals / Non-Goals + +**Goals:** + +- A branch that waits never holds a sibling that can work. Head-of-line + blocking is removed inside one run, as it already is between runs. +- Token loss under concurrent writers is IMPOSSIBLE by construction, not + improbable. Every claim about safety below is a structural argument, not a + probability. +- A run's log is a function of the path taken, not of the timing. Two runs of + one flow that take the same path read identically. +- The blast radius of intra-run fan-out is the same as per-item fan-out's, with + the same two numbers. +- Nothing that queries runs today changes meaning: no new status value, no new + index on a hot path, no rename of a place. + +**Non-Goals:** + +- **Threads.** PHP-FPM and cron are processes; this design does not pretend + otherwise. "Simultaneously" has two precise meanings here and neither is + intra-process branch threading — see Decision 8. +- **Reordering work for throughput.** The scheduler is round-robin over + streams. Priority branches, weights and deadline scheduling are not modelled; + a stream's ordinal is for READING the log, never for deciding who runs next. +- **Distributed coordination beyond the database.** No Redis, no advisory lock + server, no `ILockingProvider`. The claim lives in a table so it is visible, + reapable and survives a process that vanishes. +- **Cross-run parallelism changes.** Runs already advance independently; the + worker's `BATCH = 25` (`FlowRunWorker.php:62`) is untouched. +- **Retry of a branch.** A recovered branch fails; retry still queues a NEW run. +- **Author-visible parallelism syntax.** A split is already an edge shape and + `join: true` already a node flag — see proposal.md, What does NOT change. + +## Decisions + +### Decision 1: a firing claims the PLACES it touches, not the run and not the stream + +Three granularities were considered. + +**A — lock the run row for the whole pass.** Correct and trivial, and it is +today's behaviour with extra machinery: two branches sharing nothing would +still be mutually exclusive, which is the thing this change exists to remove. +Rejected. + +**B — claim the STREAM.** Rejected for two structural reasons, not for +performance. A join consumes one token from EACH of its input places, so its +firing spans several streams at once and a stream-granular claim cannot express +"I need all of these". And a split CREATES streams: the claimer would have to +mint stream identities before it knows which transition it is going to fire. + +**C — claim the place set of a candidate firing (chosen).** In a Petri net two +transitions conflict exactly when their place sets intersect, and Symfony hands +that set over already: `$transition->getFroms()` and `$transition->getTos()`, +the same two calls `advanceItems()` makes at `FlowItemPlacement.php:163`, +`:170` and `:182`. Claiming `froms ∪ tos` therefore claims precisely the +conflict relation the graph already has — no approximation, nothing invented. + +Outputs are claimed as well as inputs, not only inputs. Two firings that +consume from different places but PRODUCE onto the same one are in conflict: +openregister#2488 is exactly that shape, and it is recorded at +`FlowItemPlacement.php:143-155` as having been invisible in the run log. +Claiming inputs alone would leave that case unguarded. + +Storage — `openregister_flow_claims`: + +| column | why | +|---|---| +| `run_uuid`, `place` | the claim, UNIQUE together — the mutual-exclusion primitive | +| `owner` | the holder's pass token, so a reaped claim names who abandoned it | +| `stream_id`, `transition` | what the claim was taken FOR, so recovery can fail the right branch with a message a person can act on | +| `claimed_at` | the reaper's input, compared against the existing cutoff | + +The unique index IS the lock. Acquisition is an `INSERT`; a unique-violation is +a refusal, returned immediately, and the caller abandons that candidate and +tries the next one (`flow-parallel-streams` — "A contended claim is skipped, +never waited on"). No `SELECT ... FOR UPDATE` is taken on the claim table, and +nothing waits on a claim, ever. + +**Each claim insert commits on its own, before dispatch.** This is not an +implementation detail, it is the property that makes acquisition non-blocking: +an `INSERT` that collides with another transaction's UNCOMMITTED insert BLOCKS +on the row lock until that transaction ends. If the claim were taken inside the +firing's transaction, a second worker's attempt would wait for the first +worker's whole step — head-of-line blocking re-entering through the back door, +and worse than today because it would hold a database connection while it +waited. + +### Decision 2: claims are taken in one fixed total order, which is what makes deadlock and livelock impossible + +The place set is sorted with a plain byte comparison and claimed low-to-high. +On any refusal the worker DELETEs the claims it already holds for that attempt +and moves on. + +Deadlock is impossible because nothing waits: a refusal is immediate and +releasing is unconditional. The ordering buys the second property, which +non-blocking alone does not give — **freedom from livelock**. Let P be the +lowest place any two contending workers both want. Whichever wins P has, by the +ordering, already taken every place it needs BELOW P; and the loser cannot hold +any of those, because it too would have had to take them below P and would then +have won P first. So the winner of the lowest contended place always completes +its acquisition. Progress is guaranteed at every contention point, not merely +likely. + +Without the ordering, A wanting `{a,c}` and B wanting `{c,a}` can take `a` and +`c` respectively, both refuse, both release, both retry — forever, at full +speed. That is not a rare interleaving; it is the stable state under load. + +### Decision 3: the marking is a delta committed under a run-row lock, and the claim is what makes the delta CORRECT + +Two mechanisms, doing two different jobs. Conflating them is the trap. + +**The run-row lock gives ATOMICITY.** The commit path is: + +``` +BEGIN + SELECT ... FROM openregister_flow_runs WHERE uuid = ? FOR UPDATE + marking := the value just read, inside the lock + marking := marking minus one token per `from`, plus one per taken `to` + place_items := drop the froms, set the taken tos + INSERT the step row (stream_id, ordinal_path, sequence from the stream row) + UPDATE the stream row (status, resume_at, next_sequence + 1) + UPDATE the run (marking, place_items, firings + 1, derived status, updated) +COMMIT +``` + +Every value written is computed from the row read INSIDE the lock. The delta +itself — "remove `a`, add `c`" — does not mention any other place, so +committing it cannot disturb one. This is the whole difference from +`setMarking()` at `FlowRunMarkingStore.php:102-105`, which writes an entire +places map computed from a read taken before the step ran. + +**The claim gives EXCLUSION for the side effect.** The lock cannot do this job: +`$dispatcher->dispatch()` (`FlowEngine.php:427`) writes objects, sends +messages and calls remote systems, and a transaction cannot roll those back. +Two workers that both selected the same transition would both dispatch and only +then discover the conflict. So the claim is taken and committed BEFORE +dispatch, and the expensive work happens entirely outside the lock. The lock's +critical section contains no I/O and no user code. + +**Worked interleaving.** Marking `{a: 1, b: 1}`. Transition T1 consumes `a` +produces `c`; T2 consumes `b` produces `d`. Place sets `{a,c}` and `{b,d}` — +disjoint, so both must be allowed (`flow-parallel-streams` — "Two disjoint +branches fire at the same time"). Workers A and B are separate OS processes. + +``` +t Worker A (T1) Worker B (T2) +-- ------------------------------------ ------------------------------------ + 1 read run row: marking {a:1, b:1} + 2 read run row: marking {a:1, b:1} + 3 candidate T1; places sorted [a, c] + 4 candidate T2; places sorted [b, d] + 5 INSERT claim(a) — committed + 6 INSERT claim(b) — committed + 7 INSERT claim(c) — committed + 8 INSERT claim(d) — committed + 9 firstRefusal() -> null (consents) +10 firstRefusal() -> null (consents) +11 dispatch T1 ... 4.0 s of remote I/O +12 dispatch T2 ... 0.2 s +13 BEGIN; SELECT run FOR UPDATE [holds] +14 marking read IN LOCK: {a:1, b:1} +15 delta -b +d -> {a:1, d:1} +16 write marking, place_items[d], step +17 firings := firings + 1; COMMIT +18 DELETE claim(b), claim(d) +19 BEGIN; SELECT run FOR UPDATE [holds] +20 marking read IN LOCK: {a:1, d:1} <-- NOT the value read at t=1 +21 delta -a +c -> {d:1, c:1} +22 write marking, place_items[c], step +23 firings := firings + 1; COMMIT +24 DELETE claim(a), claim(c) +``` + +The stale read at t=1 is used for ONE purpose: choosing a candidate. It never +reaches a write. A re-reads at t=20 and sees B's `d`, so `d` survives; B never +saw `c` because `c` did not exist when B held the lock, and B's delta never +mentions `c`. Both effects are present. Order of the two commits is irrelevant: +swap t=13 and t=19 and the same argument runs with the letters exchanged. + +Today, the same interleaving corrupts twice over. B would write +`$marking->getPlaces()` for the `Marking` it built from the t=2 read, i.e. +`{a: 1, d: 1}` — and if A commits first, that write both DROPS `c` (a token +lost) and RESURRECTS `a` (a token A already consumed, so T1 becomes enabled +again and its side effect runs a second time). Not "unlikely": guaranteed, +whenever two branches of one run are advanced by overlapping passes. + +**The same-transition case.** Two workers that both select T1 both want `a`. +The unique index on `(run_uuid, place)` admits exactly one INSERT; the loser +never reaches t=9, so it does not consult oversight, does not dispatch, does not +write the marking and does not record a step — which is the four-part assertion +the spec makes. + +### Decision 4: a join is correct because its claim is its input set, and its wake-up is guaranteed by the last committer + +A join `j` with incoming edges `e1, e2` has input places `j#e1` and `j#e2` +(`FlowGraph.php:103-105`). Three failure modes, each closed by a different part +of the protocol: + +- **Two branches arriving at genuinely the same instant.** Branch 1's arriving + transition touches `{p1, j#e1}`, branch 2's touches `{p2, j#e2}`. Disjoint — + both claim, both dispatch, both commit under the lock in some order. The + second commit's in-lock read already contains the first's token, so the + marking that results has BOTH inputs marked. This is Decision 3's argument + with no addition. +- **Firing twice.** The join's own place set is `{j#e1, j#e2} ∪ tos`. Claiming + it requires BOTH input places, and the unique index admits one holder per + place, so two workers cannot both be inside the join's firing. Exactly once, + by the same primitive that gives exactly-once anywhere else. +- **Firing early.** Impossible without new code: while branch 2's arrival is + still in flight, `j#e2` is not marked, so Symfony does not report the join + enabled. The claim adds nothing here and does not need to. + +The real hazard is the **lost wake-up**: branch 2 commits the last arrival at +the very end of a pass whose workers have already finished their own loops. +Nobody re-evaluates, the join sits enabled, and — under today's rule at +`FlowEngine.php:312-322`, where an empty enabled set means COMPLETED — a worker +finishing its loop could declare the run complete with tokens stranded on the +join's inputs. + +So terminality stops being a conclusion a worker draws from its own loop: + +> **Only a writer holding the run-row lock may declare a run parked or +> terminal, and only from the marking it has just written.** + +Concretely, the commit at Decision 3 ends by asking `getEnabledTransitions()` +against the marking it wrote. If anything is enabled and unclaimed, the run's +derived status is `queued`, which `findQueued()` (`FlowRunMapper.php:643`) +drains on the next pass with no new query, no new index and no new scan. The +commit that MAKES the join enabled is therefore the same commit that re-arms +the run. A missed pickup costs one cron period of latency; it can never be a +lost wake-up, because the arming is inside the transaction that created the +condition. + +### Decision 5: `sequence` becomes position WITHIN a stream, and order comes from a declaration-derived ordinal path + +`FlowConcurrency`'s ORDER property (`FlowConcurrency.php:25-28`) says a run log +whose order depends on which call returned first is not comparable between two +runs. Two things break that for branches: + +1. `highestSequence() + 1` (`FlowRunService.php:677`) is a read-then-insert + outside any lock — two branches read the same maximum and write the same + number. +2. Even made atomic, a single run-wide counter handed out as rows are written + IS completion order wearing a sequence number. The spec names this trap + directly. + +So `openregister_flow_steps` gains `stream_id` and `ordinal_path`, and +`sequence` is reinterpreted as position within the stream, allocated from +`next_sequence` on the stream row by the same locked transaction that commits +the delta (the `incrementScope()` shape at `lib/Db/SequenceMapper.php:84-93`). +Canonical order becomes `ORDER BY ordinal_path ASC, sequence ASC, id ASC`, +replacing the bare `ORDER BY sequence ASC` at `FlowRunStepMapper.php:62`. + +**`ordinal_path` is a zero-padded dotted path.** The root stream is `0001`. A +firing that produces tokens on K taken output places gives its children +`parent.0001 … parent.000K`, where the index is the position in +`$transition->getTos()` — which is the order the AUTHOR wrote the edges in the +flow document. Padding to four digits per segment makes lexicographic ordering +equal tree ordering without any parsing, and `'' < '.'` puts a parent's own +steps before its children's. + +**A join folds the path back.** The merged stream takes the longest common +prefix of its inputs' paths — `0002.0001` and `0002.0002` join to `0002` — and +resumes THAT stream's `next_sequence`. A split-and-join therefore reads as one +history with a fan-out in the middle rather than as an orphan branch, and the +rule is total: for branches that came from different splits the common prefix is +shorter, possibly the root, and it is still deterministic. + +Determinism follows because every segment is a function of (parent path, index +in declaration order of the taken exit) and nothing else. Two runs that take the +same path produce identical paths and identical per-stream sequences whatever +the timing — which is exactly the spec's "Two runs, different timing, identical +ordering". `created` stays on every row (`FlowRunService.php:699-700`), so the +real interleaving remains readable on request and is never the default. + +### Decision 6: intra-run fan-out reuses layer 1's numbers, and the pass gets its own ceiling + +The cap on streams of ONE run holding claims simultaneously is +`FlowConcurrency::DEFAULT_LIMIT` (5, `FlowConcurrency.php:72`), clamped by +`MAX_LIMIT` (20, `:83`) through the same `max(1, min(...))` as +`boundedLimit()` (`:188-194`). Not a similar pair of numbers — the same two +constants, referenced. + +The argument is `FlowConcurrency`'s own, transposed: a node fanning out over +1,000 items and a run fanning out over 1,000 tokens hit the same upstream, and +a second and different pair of numbers would mean the load an API sees depends +on which layer happened to fan out. The constants stay where they are and this +layer reads them; duplicating the values into a second class is how the two +drift apart in a later edit. + +A second bound is required because the first composes multiplicatively: +`BATCH = 25` runs (`FlowRunWorker.php:62`) times five streams is 500 firings a +pass could hold at once. So a pass also carries a total ceiling on claims held +across all runs, defaulting to `BATCH × DEFAULT_LIMIT`, which makes the +per-run cap safe to raise without any single change turning a pass into a +burst. + +### Decision 7: status stays derived, with no eighth value + +Adding a status for "partly running, partly waiting" would remove such runs from +`findQueued()` (`FlowRunMapper.php:643`), `findDue()` (`:503-507`) and +`findStale()` (`:543-547`) at once — with no error, because a `WHERE status =` +that matches nothing looks exactly like a table with nothing to do. The run +would be invisible to the queue and to recovery simultaneously. + +So the run's `status` becomes a projection of its streams, written by whichever +commit last held the lock: + +| condition on the run's streams | run status | +|---|---| +| any stream holds a live claim | `running` | +| else any stream has an enabled transition | `queued` | +| else every stream parked | `suspended` | +| else every stream terminal | most severe: `failed` > `dead_letter` > `stopped` > `completed` | + +`running` therefore means "a branch is actually being worked on right now", +which is precisely how `findStale()` and `touch()`'s `WHERE status = running` +guard (`FlowRunMapper.php:247-252`) already read it. The spec's "a long-waiting +branch does not make the run look abandoned" then falls out with no special +case: a run whose only remaining stream waits on a signal holds no claim, so it +is `suspended`, so `findStale()` never sees it. + +`resume_at` is `MIN` over the streams' non-null wake times, and is null only +when EVERY stream is waiting on a signal. Computing it as a plain `MIN` over +all streams is the trap: one signal-waiting stream would make the whole run's +wake time null and a sibling's due timer would never be picked up by +`findDue()`. + +Per-branch detail — which branch waits, on what, since when — is answered from +`openregister_flow_streams` (`run_uuid`, `stream_id`, `ordinal_path`, +`parent_stream_id`, `status`, `resume_at`, `next_sequence`, `error`), never by +overloading the run's status. + +Stream status reuses `FlowRun`'s seven constants (`lib/Db/FlowRun.php:98-110`) +rather than defining a parallel vocabulary: a stream is a run-shaped thing, and +one set of strings means `TERMINAL` (`:117-121`) and `ACTIVE` (`:134-137`) +apply to both. + +### Decision 8: "simultaneously" means two precise things, and neither is PHP threads + +Stated plainly because the specs use the word and a reader could take it for +something PHP cannot do. + +1. **Across processes — genuinely parallel, and real today.** Overlapping cron + passes, and an HTTP request completing a task while a cron pass advances the + same run, are separate OS processes hitting one database. This is where the + claim protocol earns its keep, and it is also where today's lost update + already lives. +2. **Within one pass — interleaved, not threaded.** The pass walks a run's + streams round-robin instead of draining one to exhaustion, so a stream that + suspends yields to its siblings rather than returning the whole run + (`FlowEngine.php:474-493` today returns). That alone removes head-of-line + blocking, which is the user-visible complaint, and it needs no threads. + +Real in-flight overlap inside one PHP process exists only where +`FlowConcurrency::map()` already provides it — per item, inside one node +(`FlowConcurrency.php:117-172`). Branch-level overlap within a single process is +NOT promised. The cap of Decision 6 is enforced as "claims held for one run", +which is meaningful and checkable across processes; that is the reading of the +spec's "at most five in flight". + +### Decision 9: an advance budget follows the token, and contention ends it rather than blocking it + +ADR-098 D9's `advance: 0 | N | "all"` on task completion becomes a loop over +the COMPLETING stream that takes claims exactly as a worker does, decrementing +the budget per committed firing. Three consequences, each of which is a +scenario in the spec: + +- **A sibling's claim ends the advance.** The completion request returns the + run's state as it stands and leaves the rest to the queue. It never waits — + Decision 1 forbids waiting anywhere, and a person pressing Approve must not + pay for a sibling's remote call. +- **A join reached within the budget fires within it.** The join consumes the + completing branch's `j#eK`, so it is in that stream's own conflict set; the + budget follows the token, not the label. If the sibling has not arrived, the + join simply is not enabled and the advance ends there. +- **`"all"` is bounded by the same three things a worker is.** The run-wide + firing ceiling (Decision 10), the per-hop oversight check, and the request's + runtime budget (`FlowRunService::DEFAULT_MAX_RUNTIME_MINUTES`, + `lib/Service/Flow/FlowRunService.php:63`, `:246-251`). `"all"` means "keep + going while this branch can", not "ignore the bounds". + +### Decision 10: the firing ceiling becomes a persisted per-run counter + +`$fired` is a local initialised at `FlowEngine.php:299` and compared to +`MAX_TRANSITIONS = 1000` (`:103`, checked at `:325`). It resets on every +`execute()`, so a cycle that suspends once per lap never trips it — a hole that +exists today, before any of this. Branch concurrency would multiply both the +count and the number of ways to park. + +So the count moves onto the run as a column, incremented by the same locked +transaction that commits each delta (Decision 3), and checked against the same +constant. Reaching it fails the run with the existing message +(`FlowEngine.php:335`) rather than truncating silently — the posture at +`:96-102` is unchanged, only its scope. + +Incrementing inside the transaction is what makes the count exact under +concurrency: an increment outside it would be a read-modify-write of the same +shape this design just removed from the marking. + +### Decision 11: oversight is checked inside the claim, and a refusal ends the run + +`assertOversightAllows()` stays immediately before dispatch +(`FlowEngine.php:425`), now inside the claim and per firing. It is not hoisted +to once per pass and not cached across firings: the check exists so a switch +thrown mid-run is honoured on the next hop, and a per-pass check would let a +run with ten enabled firings sail past a refusal thrown after the first. + +A refusal ends the RUN. Streams that have not started do not start; a stream +already inside `dispatch()` finishes that firing — its side effect is in flight +and cannot be unmade — commits its result so the log is not missing a step that +really happened, and then stops without beginning another. The refusing check's +id is recorded, as it already is via `FlowStop::checkId()` +(`FlowEngine.php:454-456`). A check that throws is still a refusal +(`FlowOversightRegistry.php:106-120`) — unchanged, and worth noting because a +concurrency change is exactly where a fail-open shortcut would be tempting. + +### Decision 12: per-place items are persisted, seeded from today's flat array + +`place_items` (JSON) joins `marking` on `openregister_flow_runs`, written by +the same locked transaction so a marking can never name a place whose items +were not written. On first read of an in-flight run it is seeded exactly as +`seedPlaceItems()` seeds today — the same list to every marked place +(`FlowItemPlacement.php:90-103`) — so a run that spans the upgrade behaves +precisely as it does now and nothing has to be reconstructed. + +`items` on the run stays as the last firing's output +(`FlowRunService.php:810`), because surfaces read it, but it stops being the +resume source. That is the whole of openregister#2488's remedy at the +persistence layer. + +## Declarative-vs-imperative decision (ADR-031) + +**Imperative, engine core — and there is no declarative alternative to weigh.** +ADR-031's dialect operates on register objects: `x-openregister-lifecycle` hangs +on a schema and `lib/Service/Lifecycle/TransitionEngine.php` transitions an +object. Nothing here is an object. `lib/Db/Flow.php:6-11` states the position — +a flow definition is deliberately NOT an OpenRegister object — and a marking, a +place claim, a stream ordinal and a firing counter are engine state on native +tables reached by mapper, with no schema to hang a lifecycle on and no object +write for a transition's `inputs` contract to validate. + +Nothing is taken away from the dialect, because nothing about token-level +concurrency was ever expressible in it. This change adds no notification, no +aggregation, no calculation and no relation; if "a branch was abandoned" later +needs to notify, it notifies through the ADR-031 subsystem from the same place +every other flow-side send does, not through a second mechanism. + +**No seed data.** No register or schema is introduced or modified, so ADR-001 +seed data does not apply. The equivalent obligation is the back-fill in the +migration plan below, which is a data repair with an idempotency requirement. + +## Risks / Trade-offs + +- **The run-row lock serialises one run's WRITES**, so twelve branches still + commit their deltas one at a time. → Accepted, and it is the point: the + critical section holds no I/O and no user code — a handful of statements + against rows already in cache — while `dispatch()`, measured in seconds of + remote work, is entirely outside it. Serialising microseconds to parallelise + seconds is the trade this design is making on purpose. +- **Write amplification: two to four claim rows inserted and deleted per + firing.** → Accepted. The table is narrow, hot and short-lived, and every row + is deleted by the firing that took it or by the reaper. The alternative — + Decision 1's option A — costs the feature. +- **SQLite has no row-level locking**, so `FOR UPDATE` is inert and the whole + file serialises writers. → Safe, not unsafe: the guarantee is stricter than + the design needs. It is called out so nobody reads a green SQLite test suite + as evidence that the locking works. +- **Livelock under heavy contention.** → Closed by construction in Decision 2; + additionally, a candidate skipped repeatedly is logged with its place set, so + a pathological graph is visible rather than merely slow. +- **A split inside a loop mints a stream per lap.** → Bounded by the now-durable + run-wide ceiling (Decision 10), and stream rows are pruned with their run by + the retention pass already at `FlowRunWorker.php:203`. +- **`ordinal_path` grows with nesting depth.** → Five bytes per level; a + `varchar(255)` holds roughly fifty levels of nested splits, far past any + graph a person draws. A path that would exceed the column truncates the run + with a named error rather than writing a path that sorts wrongly. +- **BREAKING at the spec level for `flow-engine`'s run-level suspension + requirement.** → Behaviour for a single-stream flow is identical, because a + flow with one stream IS the run. That equality is asserted by a test rather + than argued in prose. +- **Reading a run's log now needs the ordinal.** A consumer that sorts by + `sequence` alone sees per-stream positions interleaved. → Mitigated by + `findByRun()` returning canonical order by default (`FlowRunStepMapper.php:62` + is the only ordering in the mapper), so a consumer using the mapper is + correct without changing anything. +- **Two failure vocabularies could drift** — a stream's status and the run's + derived one. → Mitigated by reusing `FlowRun`'s seven constants for both, so + `TERMINAL` and `ACTIVE` (`lib/Db/FlowRun.php:117-137`) are the single source. + +## Migration Plan + +1. **Schema.** Create `openregister_flow_claims` with UNIQUE + `(run_uuid, place)` and an index on `(claimed_at)` for the reaper. Create + `openregister_flow_streams` with UNIQUE `(run_uuid, stream_id)` and an index + on `(run_uuid, status)`. Add `place_items` (JSON, nullable) and `firings` + (integer, notnull, default 0) to `openregister_flow_runs`. Add `stream_id` + (string, nullable) and `ordinal_path` (string 255, nullable) to + `openregister_flow_steps`. +2. **Back-fill streams.** For every non-terminal run, create one stream per + MARKED place, ordinals assigned in sorted place-name order, status copied + from the run, `resume_at` copied from the run, `next_sequence` set from + `highestSequence() + 1` so the resumed history continues rather than + restarting. A run with no marked places (queued, never started) gets no + stream row; its first firing mints the root. +3. **Honest caveat on step 2.** A pre-upgrade run's ordinals are place-name + order, not the author's declaration order, because declaration order was + never recorded for a run already in flight. Such a run is therefore not + ordinal-comparable with one started after the upgrade. This is stated in the + migration's own log line rather than left for someone to discover from a + diff that will not explain itself. +4. **Back-fill steps.** Stamp existing step rows with the root path `0001` and + their run's root stream id, so `ORDER BY ordinal_path, sequence` reproduces + today's order for every historical run exactly. +5. **Back-fill items.** Leave `place_items` null; it is seeded on first read + from the flat `items` by the same rule `seedPlaceItems()` uses today + (`FlowItemPlacement.php:90-103`), so an in-flight run's behaviour across the + upgrade is unchanged rather than reconstructed. +6. **Idempotency.** Every step is guarded on existence, so a second run creates + no duplicate stream and re-stamps no step. +7. **Rollback.** All additions are additive and the old code reads none of + them: a null `stream_id` is the single implicit stream, and `sequence` + retains its old meaning for every back-filled row. Reverting the app code + restores today's behaviour with the new columns inert. Dropping them is a + separate, optional migration and MUST NOT be part of the rollback path. +8. **Verification.** After deploy: no claim row is older than the reaper's + cutoff; every non-terminal run has at least one stream unless its marking is + empty; no step row has a null `ordinal_path`; every run's `status` equals the + projection of its streams under Decision 7's table; and no run's `firings` + exceeds `MAX_TRANSITIONS`. + +## Open Questions + +- The retention horizon for `openregister_flow_claims` rows the reaper releases + — deleted immediately, or kept briefly as evidence of an abandonment. + Deferrable: it changes no spec, no interface and no task, and it cannot be + chosen sensibly before there is a fleet's worth of abandonment data. +- Whether the per-run stream cap should be author-configurable per flow (as a + node's concurrency limit already is) or remain instance-wide. Deferrable: the + clamp of Decision 6 makes both safe, and adding a per-flow source later + changes no stored shape. diff --git a/openspec/changes/flow-parallel-streams/proposal.md b/openspec/changes/flow-parallel-streams/proposal.md new file mode 100644 index 0000000000..c0bb7df62a --- /dev/null +++ b/openspec/changes/flow-parallel-streams/proposal.md @@ -0,0 +1,185 @@ +--- +kind: code +depends_on: [flow-definition-versioning] +--- + +# Proposal: flow-parallel-streams + +## Summary + +Make the independent branches of ONE run advance independently. The engine +already lowers a flow to a Petri net whose marking can hold several tokens at +once; what it does not have is a walk that treats those tokens as separate +streams. Today one branch blocking on I/O, a timer or a human answer stops +every sibling, because the walk is a single loop and a suspension returns the +whole run. + +This is the engine's THIRD concurrency layer, and the only one missing: + +| Layer | Unit | Where it lives today | +|---|---|---| +| 1 | items within one node | `lib/Service/Flow/FlowConcurrency.php` — bounded fan-out, input-ordered results, per-item failure isolation | +| 2 | whole sub-flows | `lib/Service/Flow/Nodes/SubFlowNode.php` — child runs advance on their own, tied by `parent_run_uuid` | +| 3 | **branches of one marking** | **nothing — this change** | + +## Why + +**A case is concurrent, and the engine serialises it.** ADR-098 Decision 7: +an advies request, a hoorzitting and a document check on one bezwaar are +genuinely simultaneous. Modelled as a split, they are three tokens in one +marking — and the moment any one of them reaches a human task, all three stop. + +**Measured, in the code as it stands (2026-08-22):** + +- **One loop, one transition at a time.** `FlowEngine::run()` is a single + `while (true)` (`lib/Service/Flow/FlowEngine.php:310-546`). Each pass asks + `getEnabledTransitions()` for every enabled transition and then hands the + list to `selectTransition()`, which returns exactly ONE + (`FlowEngine.php:344`, `:712-737`). Several enabled transitions are fired in + succession, never concurrently. +- **A suspension is run-wide, by design and by spec.** The `FlowSuspension` + catch returns the whole run as `suspended` + (`FlowEngine.php:474-493`), and `openspec/specs/flow-engine/spec.md:50` says + so in as many words: "`FlowSuspension` stops the WHOLE run and stores its + marking; it is not scoped to the branch that threw it." A human task on one + branch therefore parks the other two, and `resume_at = null` means the run + waits for a signal that has nothing to do with them. +- **Resuming a multi-token run already loses per-branch data.** + `FlowItemPlacement::seedPlaceItems()` seeds the per-place item buffers by + assigning the SAME persisted list to every marked place + (`lib/Service/Flow/FlowItemPlacement.php:90-102`), and the run persists one + flat `items` array — the last firing's output + (`lib/Service/Flow/FlowRunService.php:810`). A run that suspends holding two + tokens resumes with both branches carrying one branch's items. Serialised, + this is rare; with branches genuinely in flight it is the normal case. +- **The marking is a read-modify-write of one JSON column.** + `FlowRunMarkingStore::setMarking()` writes `$marking->getPlaces()` wholesale + onto the run (`lib/Service/Flow/FlowRunMarkingStore.php:102-105`) from a + `Marking` read at the top of the pass. Two writers, one lost update, tokens + gone with no error anywhere. Items already collided once on a shared output + place — `FlowItemPlacement.php:118-122` records it as openregister#2488, and + notes it "is invisible" in the log. +- **The run log's order is insert order.** `recordSteps()` numbers rows from + `highestSequence() + 1` (`FlowRunService.php:669-732`) and + `FlowRunStepMapper::findByRun()` sorts `ORDER BY sequence ASC` + (`lib/Db/FlowRunStepMapper.php:62`). Concurrent branches make that number a + record of which branch returned first — the exact property `FlowConcurrency` + refuses to give up for items: "a run log whose order depends on which call + returned first is not comparable between two runs of the same flow" + (`FlowConcurrency.php:25-28`). +- **The transition ceiling is per-pass, not per-run.** `$fired` is a local + initialised at `FlowEngine.php:299` and checked against + `MAX_TRANSITIONS = 1000` (`FlowEngine.php:103`, `:325`). It resets on every + `execute()`, so a cyclic graph that suspends each pass never trips it. That + hole exists today across suspend/resume; branch parallelism would widen it + from "a slow loop" to "N slow loops". + +The two invariants ADR-098 D7 names are therefore not decorations — each has a +concrete counterexample in the file it names. + +## What Changes + +- **A stream is a first-class, persisted thing.** A run's marking is + partitioned into streams; each stream has an id, an ordinal, a status and a + claim. Run status stays derived from them, so nothing that queries `status` + today changes meaning. +- **Per-branch claiming, with the database as the mutual-exclusion + primitive.** A worker claims the PLACES a firing touches (its inputs and its + outputs) before dispatching it, via unique-constrained rows in a new + `openregister_flow_claims` table, taken in a fixed order. Two firings that + share any place can never both be claimed; a firing whose claim fails is + skipped, never blocked on. +- **Marking writes become deltas under a row lock.** The marking mutation for + one firing removes tokens from the consumed places and adds them to the + produced ones, inside a short transaction that locks the run row — never a + whole-marking overwrite computed from a stale read. A lost update stops being + unlikely and becomes unrepresentable. +- **Suspension becomes stream-scoped.** `FlowSuspension` parks the stream that + raised it and releases its claim; sibling streams keep going. The run parks + only when every stream is parked. **BREAKING** at the spec level for + `flow-engine`'s "SUSPENDING is a run-level act" requirement; behaviour for a + single-stream flow is unchanged, because a flow with one stream IS the run. +- **Per-place item buffers are persisted per place.** A new `place_items` + column, seeded on first read from the existing flat `items` so an in-flight + run keeps today's behaviour exactly. +- **Run-log order is by branch, never by completion.** A step row records its + stream id and the stream's declaration ordinal; a run's canonical order is + `(stream ordinal, sequence within stream)`, which is identical between two + runs of the same flow. Wall-clock timestamps stay available and are never the + default order. +- **Bounding reuses layer 1's posture and numbers.** A per-run cap on streams + advanced in one pass, defaulting to `FlowConcurrency::DEFAULT_LIMIT` (5) and + clamped by `MAX_LIMIT` (20), so intra-run fan-out cannot be a burst that + per-item fan-out would have refused. +- **Crashed claims are reaped like stale runs.** `FlowRunWorker`'s existing + cutoff — `max(flow_run_stale_minutes, flow_max_runtime_minutes + 5)`, + `lib/Cron/FlowRunWorker.php:251-261` — also releases abandoned claims, and + keeps that pass's posture: a reaped stream FAILS rather than silently + re-running side effects it may already have performed + (`FlowRunWorker.php:208-218`). +- **The transition ceiling becomes run-scoped and durable.** A persisted count + of committed firings, checked against `MAX_TRANSITIONS`, so the ceiling + survives suspension and covers all streams together. +- **Oversight stays per-hop and fail-closed.** `FlowOversightRegistry:: + firstRefusal()` is consulted inside each claim before each dispatch, never + hoisted per pass. A refusal stops the RUN, not one branch: a kill switch that + leaves a sibling writing objects is not a kill switch. +- **Advance budgets (ADR-098 D9) follow the token.** `advance: 0 | N | "all"` + on task completion advances the COMPLETING stream, in-request, taking claims + exactly as the worker does. Siblings are untouched, and a sibling's claim + ends the in-request advance rather than blocking the request. + +## What does NOT change + +- **The flow document.** Authors already express parallelism declaratively — a + split is an edge shape, `join: true` is a node flag, and + `FlowGraph::joinPlace()` already gives a join one input place per incoming + edge (`lib/Service/Flow/FlowGraph.php:103-105`). This change executes that + declaration; it does not add a way to write it. +- **`FlowGraph::inPlace()` returning the bare node id.** It is load-bearing for + per-item routing and for the "where is this run?" badge + (`FlowGraph.php:46-69`); stream identity is recorded beside the marking, not + encoded into place names. +- **The seven run statuses.** No eighth value: a new status would silently drop + out of every existing `WHERE status = ...`, including `findQueued()`, + `findDue()`, `findStale()` and `hasActiveRun()`. +- **Layers 1 and 2.** Per-item concurrency and sub-flow fan-out are unchanged; + this composes beneath them. +- **Retry semantics.** Retry still queues a NEW run; a stream is never + restarted in place. + +## Capabilities + +### New Capabilities +- `flow-parallel-streams`: independent branches of one run's marking advance + simultaneously, with marking consistency under concurrent writers, a + branch-ordered run log, a bound on intra-run fan-out, and crash recovery for + abandoned branch claims. + +### Modified Capabilities +- `flow-engine`: "SUSPENDING is a run-level act, so an EMPTY firing MUST NOT + suspend" — suspension becomes stream-scoped, so the requirement's premise + changes while its empty-firing rule stands (for a new reason: an empty branch + that parks forever holds a join open rather than stopping the run). + +## Impact + +- **Affected specs**: new `flow-parallel-streams`; `flow-engine` (one modified + requirement) +- **Affected code**: `FlowEngine` (the walk becomes per-stream), + `FlowRunAdvancer` (claiming and the pass budget), `FlowRunMarkingStore` + (delta writes), `FlowItemPlacement` + `FlowRunService` (per-place item + persistence), `FlowRunStep`/`FlowRunStepMapper` (stream id and ordinal), + `FlowRunWorker` (claim reaping), plus migrations for + `openregister_flow_claims`, `openregister_flow_streams` and the new run and + step columns +- **Affected apps**: every consumer of the shared engine (ADR-022, ADR-065) + gains real concurrency without touching its flows; hermiq's node badge reads + a marking that may now legitimately hold several tokens at once +- **Depends on** `flow-definition-versioning`: a stream may outlive several + worker passes, so every stream of one run MUST resolve the SAME pinned + definition. Without the pin, `FlowRunAdvancer.php:92` re-resolves the live + definition each pass and two streams of one run could walk two different + graphs +- **ADRs**: ADR-098 D7 (this decision), D9 (advance budgets), ADR-065 (one + engine), ADR-031 (no declarative surface is touched — justified in design.md) diff --git a/openspec/changes/flow-parallel-streams/specs/flow-engine/spec.md b/openspec/changes/flow-parallel-streams/specs/flow-engine/spec.md new file mode 100644 index 0000000000..a66d2da234 --- /dev/null +++ b/openspec/changes/flow-parallel-streams/specs/flow-engine/spec.md @@ -0,0 +1,61 @@ +## REMOVED Requirements + +### Requirement: SUSPENDING is a run-level act, so an EMPTY firing MUST NOT suspend @e2e exclude engine-internal suspend rule — covered by WaitNodeTest + +**Reason**: Its premise is what this change removes. The requirement is built on +"`FlowSuspension` stops the WHOLE run and stores its marking; it is not scoped +to the branch that threw it", which is exactly the behaviour +`flow-parallel-streams` replaces with stream-scoped suspension. Leaving the +requirement in place would leave the spec asserting the opposite of the engine. + +**Migration**: The rule it protects — an empty firing MUST NOT suspend — is +preserved verbatim, with all three of its scenarios, in the replacement +requirement "SUSPENDING is a STREAM-level act, and an EMPTY firing MUST NOT +suspend" below. No behaviour an author relies on changes for a flow with a +single branch, because a single-stream run IS the run. + +## ADDED Requirements + +### Requirement: SUSPENDING is a STREAM-level act, and an EMPTY firing MUST NOT suspend @e2e exclude engine-internal suspend rule — covered by WaitNodeTest + +`FlowSuspension` parks the STREAM that raised it and stores the run's marking; +it does not park the branches that did not raise it. The run parks when every +stream has parked. A transition MAY fire with no items — a routing node sent +every item down another branch, or that branch had no work this pass — and a +node that waits MUST return those items unchanged rather than suspend. + +The empty-firing rule survives the change of scope, for a sharper reason. An +empty branch that parks no longer stops the branch that DID carry an item, but +it still parks a stream that will never be woken by anything, and a stream in +that state holds open every join downstream of it: the join waits for a token +that a parked-forever branch will never deliver, and the run cannot reach a +terminal state. Where branches are PRIORITIES rather than alternatives — a +preferred branch evaluated first, falling through when it is empty — an empty +branch reaching a wait is the normal case, not an error. + +Nothing is deferred by returning early. With no items there is nothing to +delay, and a later pass that DOES carry items reaches the node and suspends the +stream then. + +#### Scenario: An empty branch does not pause the run +- **GIVEN** a flow whose routing node sends its only item to a collect branch, leaving a dispatch branch that also contains a wait +- **WHEN** the wait on the empty dispatch branch fires with no items +- **THEN** the run MUST NOT suspend +- **AND** the collect branch MUST advance in the same pass + +#### Scenario: A wait carrying work still suspends +- **GIVEN** the same wait node and configuration +- **WHEN** it fires with one or more items on its first pass +- **THEN** it MUST suspend its own stream with the resolved `resumeAt` +- **AND** a sibling stream with work MUST keep advancing + +#### Scenario: A resumed wait passes its items through +- **GIVEN** a run woken by the worker because `resumeAt` has passed +- **WHEN** the wait node runs a second time with `resuming` set +- **THEN** it MUST return its items unchanged rather than suspend again + +#### Scenario: A one-branch flow is unchanged +- **GIVEN** a flow whose marking never holds more than one token +- **WHEN** any node suspends +- **THEN** the run MUST park exactly as it does today, with the same stored + marking and the same `resumeAt` diff --git a/openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md b/openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md new file mode 100644 index 0000000000..bd181687b5 --- /dev/null +++ b/openspec/changes/flow-parallel-streams/specs/flow-parallel-streams/spec.md @@ -0,0 +1,389 @@ +## Purpose + +Lets the independent branches of one flow run advance at the same time, so a +branch waiting on a person, a timer or a remote call never holds up its +siblings — while keeping the run's marking consistent under concurrent writers +and its log ordered by branch rather than by which branch finished first. + +## ADDED Requirements + +### Requirement: Independent branches of one run MUST advance independently + +A marking holding several tokens describes several things happening at once. +The engine MUST treat each such token as its own STREAM and advance streams +independently, so that a stream blocked on input/output, on a timer, or on a +human answer does not stop a sibling that has work it can do. + +A run MUST park only when EVERY one of its streams is parked, and MUST reach a +terminal state only when every stream has reached one. A single-stream run +behaves exactly as it does today: the stream IS the run. + +Parking one stream MUST NOT discard what a sibling stream is carrying. Each +stream's items MUST survive suspension separately, so a stream resumed hours +later resumes with the items ITS branch produced and no others. + +#### Scenario: A human task on one branch does not stop its siblings +- **GIVEN** a run whose marking holds three tokens — an advice request, a + hearing and a document check +- **WHEN** the advice branch suspends waiting for a person to answer +- **THEN** the hearing and document-check branches MUST continue to advance +- **AND** the run MUST NOT be reported as parked while either of them can still + fire +- @e2e exclude engine-internal walk semantics — covered by the parallel-stream + engine tests + +#### Scenario: A run parks only when every stream has parked +- **GIVEN** a run with two streams, one suspended on a signal and one suspended + on a timer +- **WHEN** the second one parks +- **THEN** the run MUST be parked +- **AND** its wake time MUST be the EARLIEST wake time among its streams, so a + stream waiting on a signal that never arrives cannot delay a stream whose + timer is due +- @e2e exclude engine-internal walk semantics — covered by the parallel-stream + engine tests + +#### Scenario: Each stream resumes with its own items +- **GIVEN** a run that suspends holding two tokens, whose branches produced + different item lists +- **WHEN** the run resumes +- **THEN** each branch MUST resume with the items its own branch produced +- **AND** neither branch's items MUST be replaced by the other's +- @e2e exclude engine-internal item placement — covered by the placement tests + +### Requirement: A firing MUST exclusively claim every place it touches + +Concurrent writers to one run's marking are where token loss would live, so the +engine MUST make the loss unrepresentable rather than unlikely. + +Before dispatching a transition, a worker MUST acquire an exclusive claim on +EVERY place that firing touches — the places it consumes from and the places it +produces onto. Two firings whose place sets intersect MUST NOT both hold claims +at the same time. Two firings whose place sets are disjoint MUST be able to +proceed simultaneously. + +Claim acquisition MUST NOT block. A worker that cannot take every place it +needs MUST abandon that firing, leave the marking untouched, and move on; the +firing stays enabled and a later attempt takes it. Waiting for a sibling's +claim would reintroduce exactly the head-of-line blocking this capability +removes. + +Claims MUST be acquired in a fixed, total order that does not depend on which +worker is asking, so two workers reaching for overlapping place sets can never +deadlock against each other. + +#### Scenario: Two workers reaching for the same place — exactly one fires +- **GIVEN** two workers that both find the same transition enabled on one run +- **WHEN** both attempt to claim it +- **THEN** exactly one MUST acquire the claim and dispatch the step +- **AND** the other MUST NOT dispatch it, MUST NOT write the marking, and MUST + NOT record a step +- @e2e exclude a concurrency property — covered by the claim tests driving two + connections + +#### Scenario: Two disjoint branches fire at the same time +- **GIVEN** a run whose marking holds two tokens on branches sharing no place +- **WHEN** two workers each claim one branch's firing +- **THEN** both MUST proceed +- @e2e exclude a concurrency property — covered by the claim tests + +#### Scenario: A contended claim is skipped, never waited on +- **GIVEN** a worker whose next candidate firing needs a place another worker + holds +- **WHEN** the claim attempt fails +- **THEN** the worker MUST move to its next candidate without waiting +- **AND** the skipped firing MUST still be enabled afterwards +- @e2e exclude a concurrency property — covered by the claim tests + +### Requirement: A marking MUST be written as a delta, never as a whole overwrite + +A whole-marking write computed from a marking read at the start of a pass is a +read-modify-write, and two of them lose a token with no error raised anywhere. + +The engine MUST persist a firing's effect on the marking as a DELTA — the +tokens it consumed and the tokens it produced — applied to the marking as it is +at the moment of the write, inside a critical section that serialises writers of +that run. The delta and the items moved by the same firing MUST be committed +together, so a marking can never name a place whose items were not written. + +After a lost-update-shaped interleaving — two firings on disjoint branches, +each reading the marking before either writes — the committed marking MUST +contain the effects of BOTH. + +#### Scenario: Two interleaved commits keep both effects +- **GIVEN** two workers that both read the marking `{a: 1, b: 1}` before either + writes +- **WHEN** one fires the transition consuming `a` and the other the transition + consuming `b` +- **THEN** the committed marking MUST show both consumed and both successors + marked +- **AND** no token MUST be present that neither firing produced +- @e2e exclude a concurrency property — covered by the marking-store tests + +#### Scenario: Marking and items commit together +- **GIVEN** a firing that produces items onto its output place +- **WHEN** its marking delta is committed +- **THEN** that place's items MUST be readable in the same committed state +- @e2e exclude persistence contract — covered by the marking-store tests + +### Requirement: A synchronising join MUST fire exactly once, however its branches arrive + +A join declared with `join: true` has one input place per incoming edge, and +fires only when every one of them is marked. Concurrency MUST NOT be able to +make it fire twice, and MUST NOT be able to make it never fire. + +Two branches arriving at genuinely the same moment deposit on DIFFERENT input +places of the join, so their firings are disjoint and both MUST be allowed to +proceed. The join's own firing consumes all of its input places at once, so +claiming it requires claiming all of them — which is what makes double-firing +impossible. + +Whether the join is noticed as enabled MUST NOT depend on the arrival order. A +worker that commits an arrival MUST re-evaluate what is enabled after that +commit. If no worker takes the join in that pass, the run MUST NOT be treated +as finished or parked while the join is enabled: the next pass MUST fire it. A +missed pickup is bounded latency; it MUST NEVER be a lost wake-up. + +#### Scenario: Simultaneous arrivals fire the join once +- **GIVEN** a join with two incoming edges, and two workers each committing one + branch's arrival at the same time +- **WHEN** both re-evaluate the marking +- **THEN** the join MUST be fired exactly once +- **AND** its step MUST read the items of BOTH branches +- @e2e exclude a concurrency property — covered by the join tests + +#### Scenario: A join nobody picked up in one pass is fired by the next +- **GIVEN** a join that becomes enabled by the last committed arrival of a pass + whose workers have all finished +- **WHEN** the next worker pass runs +- **THEN** the join MUST be fired +- **AND** the run MUST NOT have been reported as completed or parked in the + meantime +- @e2e exclude engine-internal walk semantics — covered by the join tests + +### Requirement: The run log MUST be ordered by branch, never by completion + +A log whose order depends on which branch returned first is not comparable +between two runs of the same flow — the property already asserted for per-item +concurrency, applied to branches. + +Every step record MUST name the stream that produced it and that stream's +ordinal, taken from the AUTHOR's declaration order at the split that created +it. A run's canonical ordering MUST be by stream ordinal, then by position +within the stream. Two runs of the same flow that take the same path MUST +produce the same canonical ordering, whatever the branches' relative timing. + +Positions MUST be assigned per stream, not per run: a single run-wide counter +handed out as rows are written IS completion order wearing a sequence number. + +Wall-clock timestamps MUST remain on each record, so an operator who wants to +see the real interleaving can ask for it. That MUST NOT be the default order, +and MUST NOT be what any comparison between runs is built on. + +#### Scenario: Two runs, different timing, identical ordering +- **GIVEN** two runs of one flow whose branches finish in opposite orders +- **WHEN** each run's log is read in canonical order +- **THEN** the two sequences of records MUST be identical in branch, node and + position +- @e2e exclude a determinism property — covered by the run-log ordering tests + +#### Scenario: The real interleaving is still recoverable +- **GIVEN** a run whose branches ran concurrently +- **WHEN** its records are read by timestamp instead +- **THEN** the actual interleaving MUST be visible +- **AND** that MUST NOT be the order used by default +- @e2e exclude covered by the run-log ordering tests + +### Requirement: Intra-run fan-out MUST be bounded + +An unbounded intra-run fan-out is the same hazard as an unbounded per-item +fan-out: one run over a wide split becomes a burst against machines and +upstreams that did nothing to deserve it. The bound MUST NOT be optional. + +The number of streams of ONE run advanced simultaneously MUST be capped. The +default and the hard ceiling MUST be the SAME numbers per-item concurrency +already uses — a second and different pair would mean the load an upstream sees +depends on which layer happened to fan out. A configured value above the +ceiling MUST be clamped, not honoured; a value below one MUST become one. + +A worker pass MUST also bound its total work across runs, so raising the +per-run cap cannot turn one pass into an unbounded burst. + +#### Scenario: A wide split runs within the cap +- **GIVEN** a run whose marking holds twelve independent tokens and a cap of + five +- **WHEN** a worker pass advances it +- **THEN** at most five of its streams MUST be in flight at once +- @e2e exclude a concurrency property — covered by the stream-scheduler tests + +#### Scenario: A misconfigured cap is clamped +- **GIVEN** a flow configured with a stream cap far above the ceiling +- **WHEN** the run is advanced +- **THEN** the effective cap MUST be the ceiling +- @e2e exclude covered by the stream-scheduler tests + +### Requirement: A branch abandoned by a crashed worker MUST be recovered, and MUST NOT be silently re-run + +A worker killed inside a firing never releases its claim. Left alone, that +claim blocks its branch forever while every sibling keeps running, so the run +looks alive and is permanently incomplete — the worst failure shape, because +nothing reports it. + +A claim held longer than any real firing can take MUST be released by the same +recovery pass that already recovers abandoned runs, and MUST use that pass's +existing cutoff so the two can never contradict each other. + +A recovered branch MUST be FAILED, not silently retried. It may already have +written an object, sent a message or called a remote system, and re-running it +would repeat those without saying so. The failure MUST name the branch, and +MUST apply the run's error policy — so a run whose policy is to continue keeps +its siblings, and one whose policy is to stop stops. + +Recovery MUST be visible: an abandoned claim MUST be reported, because "a +worker took a branch and died" is worth seeing even when it is recovered from. + +#### Scenario: A stale claim is released and its branch failed +- **GIVEN** a claim whose holder died mid-firing +- **WHEN** the recovery pass runs after the cutoff +- **THEN** the claim MUST be released +- **AND** the branch MUST be recorded as failed, naming the branch +- **AND** the branch MUST NOT be re-dispatched automatically +- @e2e exclude a recovery property — covered by the claim-reaper tests + +#### Scenario: A live long-running firing is not reaped +- **GIVEN** a branch inside one long step that is still within the runtime it + was granted +- **WHEN** the recovery pass runs +- **THEN** the claim MUST NOT be released +- @e2e exclude covered by the claim-reaper tests + +### Requirement: The transition ceiling MUST count a run, not a pass + +The ceiling exists because a Petri net can express a loop that never settles. +Counting only the firings of the current pass makes it defeatable by any cycle +that parks once per lap, and branch concurrency multiplies both the count and +the ways to park. + +A run's committed firings MUST be counted across ALL of its streams and across +every pass, and that count MUST be what the ceiling is checked against. +Reaching it MUST fail the run and say so, and MUST NOT silently truncate. + +#### Scenario: A cycle that parks each lap still hits the ceiling +- **GIVEN** a flow with a cycle that suspends once per lap +- **WHEN** its committed firings across passes reach the ceiling +- **THEN** the run MUST fail with a message naming the ceiling +- @e2e exclude engine-internal backstop — covered by the ceiling tests + +#### Scenario: Concurrent branches share one ceiling +- **GIVEN** a run with three streams +- **WHEN** their firings are counted +- **THEN** the ceiling MUST apply to the run's total, not to each stream +- @e2e exclude covered by the ceiling tests + +### Requirement: Oversight MUST be consulted before every firing, and a refusal MUST stop the RUN + +Oversight is consulted before every hop precisely so a long or repeatedly +resumed run cannot sail past a switch thrown mid-run. Concurrency MUST NOT +weaken that: the check MUST be made per FIRING, inside the claim and before the +step is dispatched. It MUST NOT be hoisted to once per worker pass, and MUST +NOT be cached across firings. + +A refusal MUST end the RUN, not the branch that happened to ask. A kill switch +that stops one branch while a sibling keeps writing objects is not a kill +switch. Streams that have not started MUST NOT start; a stream already inside a +firing MUST finish that firing — a side effect in progress cannot be unmade — +and MUST then stop without beginning another. The refusing check's identity +MUST be recorded. + +A check that cannot form an opinion MUST still be a refusal, exactly as it is +for a single-stream run. + +#### Scenario: A refusal reaches every branch +- **GIVEN** a run with three streams and a check that refuses +- **WHEN** the refusal is raised by one stream's next firing +- **THEN** no stream MUST begin a further firing +- **AND** the run MUST end as stopped, recording which check refused +- @e2e exclude covered by the oversight tests + +#### Scenario: A firing in flight is bounded, not abandoned +- **GIVEN** a stream already dispatching a step when another stream is refused +- **WHEN** that step returns +- **THEN** its result MUST be committed and recorded +- **AND** that stream MUST NOT begin another firing +- @e2e exclude covered by the oversight tests + +### Requirement: A run's status MUST stay derivable from its streams, with no new value + +Every surface that asks about runs filters on the existing status values, and a +run that is neither running nor suspended has no place to be. Adding a value +would remove such runs from every existing filter without any error — the +queue's own pass reads only queued and due runs, so a run outside both is +unreachable. + +The status set MUST NOT grow. A run's status MUST be derived from its streams: +it is running while any stream holds a live claim, parked when every stream is +parked, and terminal when every stream has reached a terminal state. "Running" +therefore MUST mean "a branch is actually being worked on" — which is exactly +what the abandonment recovery reads it as. + +Per-branch detail MUST be answerable — which branches are waiting, on what, and +since when — from the streams themselves, not by overloading the run's status. + +#### Scenario: One branch waiting and one working reads as running +- **GIVEN** a run with one stream suspended on a human task and one advancing +- **WHEN** the run's status is read +- **THEN** it MUST be running +- **AND** the suspended branch MUST be visible as suspended in the run's own + per-branch detail +- @e2e exclude status derivation — covered by the run-status tests + +#### Scenario: All branches parked reads as parked, and is woken normally +- **GIVEN** the same run once its advancing stream also parks +- **WHEN** the queue's due-run pass runs after the earliest wake time +- **THEN** the run MUST be picked up exactly as a single-stream parked run is +- @e2e exclude covered by the run-status tests + +#### Scenario: A long-waiting branch does not make the run look abandoned +- **GIVEN** a run with a branch that has been waiting for a signal for days +- **WHEN** the abandonment recovery pass runs +- **THEN** the run MUST NOT be failed as abandoned +- @e2e exclude covered by the run-status tests + +### Requirement: A completion's advance budget MUST apply to the completing branch + +Completing a task may advance the run in the same request by a configured +budget. With concurrent branches that budget MUST be scoped to the branch the +completion belongs to, so a person pressing Approve never pays the wall-clock +of an unrelated branch's remote calls. + +The budget MUST follow the TOKEN, not the label: if advancing the completing +branch reaches a join and enables it, firing that join is part of the budget, +because the join consumes the completing branch's place. + +An in-request advance MUST take claims exactly as a worker does and MUST NOT +wait on one. Reaching a place a sibling holds MUST end the in-request advance +and leave the rest to the queue; the request MUST return the run's state as it +stands rather than blocking. + +The run-wide ceiling and oversight MUST bound an in-request advance exactly as +they bound a worker's. + +#### Scenario: Approving one branch does not run a sibling's work +- **GIVEN** a run with a completing branch and a sibling mid remote call +- **WHEN** the task is completed with a budget that continues the run +- **THEN** only the completing branch MUST advance in the request +- @e2e exclude covered by the advance-budget tests + +#### Scenario: A budget that reaches a join fires it +- **GIVEN** a completing branch whose next place is the last unmarked input of + a join, within the budget +- **WHEN** the completion advances +- **THEN** the join MUST fire in the same request +- @e2e exclude covered by the advance-budget tests + +#### Scenario: A contended place ends the advance instead of blocking +- **GIVEN** a completing branch whose next firing needs a place a sibling holds +- **WHEN** the completion advances +- **THEN** the request MUST return without waiting +- **AND** the remaining work MUST be left to the queue +- @e2e exclude covered by the advance-budget tests diff --git a/openspec/changes/flow-parallel-streams/tasks.md b/openspec/changes/flow-parallel-streams/tasks.md new file mode 100644 index 0000000000..8004e51ed4 --- /dev/null +++ b/openspec/changes/flow-parallel-streams/tasks.md @@ -0,0 +1,197 @@ +# Tasks: flow-parallel-streams + +## 1. Storage + +- [ ] 1.1 Migration: `openregister_flow_claims` (`run_uuid`, `place`, `owner`, + `stream_id`, `transition`, `claimed_at`) with UNIQUE `(run_uuid, place)` + — the unique index IS the lock — and an index on `claimed_at` for the + reaper; `openregister_flow_streams` (`run_uuid`, `stream_id`, + `ordinal_path`, `parent_stream_id`, `status`, `resume_at`, + `next_sequence`, `error`, `created`, `updated`) with UNIQUE + `(run_uuid, stream_id)` and an index on `(run_uuid, status)`; + `place_items` (json, nullable) + `firings` (int, notnull, default 0) on + `openregister_flow_runs`; `stream_id` + `ordinal_path` (string 255, + nullable) on `openregister_flow_steps`. +- [ ] 1.2 `lib/Db/FlowStream.php` + `FlowStreamMapper` (find by run, find by + run+stream, allocate the next sequence with the conditional-UPDATE shape + of `lib/Db/SequenceMapper.php:84-93`) and `lib/Db/FlowClaim.php` + + `FlowClaimMapper` (insert-or-refuse, release by owner, count held per + run, find older than a cutoff). Stream status reuses `FlowRun`'s seven + constants (`lib/Db/FlowRun.php:98-110`) rather than a second vocabulary. +- [ ] 1.3 Repair step in the same migration: one stream per marked place on + every non-terminal run, ordinals in sorted place-name order, + `next_sequence` from `highestSequence() + 1` + (`lib/Db/FlowRunStepMapper.php:81-97`); existing step rows stamped with + the root path `0001` and the root stream id; `place_items` left null so + it seeds from the flat `items` on first read. Guarded on existence, and + logging the ordinal caveat of design.md Migration Plan step 3. + +## 2. Claim protocol + +- [ ] 2.1 `FlowPlaceClaims` — `acquire(run, streamId, transition, places)` + sorts `froms ∪ tos` bytewise, INSERTs each place in its OWN committed + transaction, and on the first unique violation DELETEs what it already + took and returns a refusal. It never waits and never retries in place. + The commit-per-insert is load-bearing: taking a claim inside the firing's + transaction would make a rival INSERT block on the row lock for the + duration of the step (design.md Decision 1). +- [ ] 2.2 Pass identity + cap enforcement: `owner` is a per-pass token + (instance, pid, pass uuid) stamped on every claim; a claim is refused + when the run already holds `FlowConcurrency::DEFAULT_LIMIT` + (`lib/Service/Flow/FlowConcurrency.php:72`) claims, clamped by + `MAX_LIMIT` (`:83`) through the same `max(1, min(...))` as + `boundedLimit()` (`:188-194`), and when the pass holds + `BATCH × DEFAULT_LIMIT` across all runs (`lib/Cron/FlowRunWorker.php:62`). + +## 3. The commit path + +- [ ] 3.1 `FlowRunCommit` — one method holding the whole critical section: + `beginTransaction()`, `SELECT ... FOR UPDATE` the run row, recompute from + the value read INSIDE the lock, apply the delta, write marking + + `place_items` + the step row + the stream row + `firings + 1` + the + derived status, `commit()`. No I/O and no user code inside it; the + dispatch stays outside. `IDBConnection` transaction handling follows + `lib/Service/SequenceService.php:76-115`. +- [ ] 3.2 `FlowRunMarkingStore::setMarking()` + (`lib/Service/Flow/FlowRunMarkingStore.php:102-105`) stops writing + `$marking->getPlaces()` wholesale and takes a delta — one token off each + `from`, one onto each taken `to`. The whole-value write is removed, not + wrapped: leaving it reachable leaves the lost update reachable. +- [ ] 3.3 Per-place items persisted: `place_items` written by the same + transaction as the marking, and `FlowItemPlacement::seedPlaceItems()` + (`lib/Service/Flow/FlowItemPlacement.php:90-103`) reads it when present, + falling back to today's same-list-to-every-place seed when null so an + in-flight run's behaviour across the upgrade is identical. + +## 4. The stream walk + +- [ ] 4.1 `FlowStreamScheduler` — round-robin over a run's advanceable streams + rather than draining one to exhaustion, bounded by task 2.2's cap. A + stream whose claim is refused yields to the next; a stream that parks + yields; neither returns the run. +- [ ] 4.2 `FlowEngine::run()` (`lib/Service/Flow/FlowEngine.php:310-546`) + becomes a per-stream walk: `FlowSuspension` (`:474-493`) parks the + stream that raised it and releases its claim instead of returning the + run, and the empty-enabled-set exit (`:312-322`) no longer decides the + run's fate. Terminality is decided only by `FlowRunCommit` from the + marking it just wrote (design.md Decision 4). +- [ ] 4.3 Stream lineage: a firing that marks K taken output places mints K + child streams with `parent.0001 … parent.000K` in `getTos()` declaration + order; a join folds its inputs back to their longest common prefix and + resumes that stream's `next_sequence`; a path that would exceed the + column fails the run with a named error rather than sorting wrongly. +- [ ] 4.4 Derived run status written by `FlowRunCommit`: `running` while any + stream holds a live claim, `queued` while any stream has an enabled + transition, `suspended` when all are parked, else the most severe + terminal (`failed` > `dead_letter` > `stopped` > `completed`). + `resume_at` is `MIN` over NON-NULL stream wake times, null only when + every stream waits on a signal — a plain `MIN` would hide a due timer + from `findDue()` (`lib/Db/FlowRunMapper.php:503-507`). No eighth status + value is added. + +## 5. Run-log ordering + +- [ ] 5.1 `FlowRunService::recordSteps()` + (`lib/Service/Flow/FlowRunService.php:669-732`) stops reading + `highestSequence() + 1` (`:677`) and takes its position from the stream + row inside `FlowRunCommit`'s transaction, writing `stream_id` and + `ordinal_path` on every row; `FlowRunStepMapper::findByRun()` then orders + `ordinal_path ASC, sequence ASC, id ASC`, replacing the bare + `ORDER BY sequence ASC` (`lib/Db/FlowRunStepMapper.php:62`). A + by-timestamp ordering is available explicitly and is never the default. + +## 6. Bounds, oversight and recovery + +- [ ] 6.1 The transition ceiling becomes the persisted `firings` count checked + against `MAX_TRANSITIONS` (`lib/Service/Flow/FlowEngine.php:103`), + replacing the per-pass local at `:299`/`:325`, and keeping the existing + failure message (`:335`) so a cycle that parks each lap now trips it. +- [ ] 6.2 `assertOversightAllows()` (`FlowEngine.php:425`) is called per firing + inside the claim, never hoisted per pass and never cached. A refusal ends + the RUN: unstarted streams do not start, a stream already inside + `dispatch()` commits that firing and then stops, and the refusing check's + id is recorded via `FlowStop::checkId()` (`:454-456`). +- [ ] 6.3 `FlowRunWorker::reapStale()` (`lib/Cron/FlowRunWorker.php:226-275`) + also releases claims older than its EXISTING cutoff (`:251-261`) — the + same expression, not a second constant — fails the abandoned stream + naming the branch, applies the run's error policy to its siblings, and + logs the abandonment. Reaped, never re-dispatched, matching `:207-218`. + +## 7. Advance budget (ADR-098 D9) + +- [ ] 7.1 Task completion's `advance: 0 | N | "all"` advances the COMPLETING + stream only, taking claims through `FlowPlaceClaims` exactly as a worker + does. A refused claim ENDS the advance and returns the run's state; a + join consuming the completing branch's place is inside the budget; + `"all"` remains bounded by the ceiling, by per-firing oversight and by + `FlowRunService::DEFAULT_MAX_RUNTIME_MINUTES` (`FlowRunService.php:63`). + +## 8. Tests + +- [ ] 8.1 Concurrency properties, driven through two real database + connections: the t=1..24 interleaving of design.md Decision 3 leaves both + effects committed; two workers on one transition produce exactly one + dispatch, one marking write and one step row; two disjoint branches both + proceed; a contended claim is skipped and the firing stays enabled; a + join with simultaneous arrivals fires exactly once reading both branches' + items; a join enabled by the last commit of a finished pass is fired by + the next pass and the run is never reported completed in between. +- [ ] 8.2 Determinism and regression: two runs whose branches finish in + opposite orders produce identical canonical logs; the real interleaving + is still readable by timestamp; a twelve-token marking holds at most five + claims; a cap above the ceiling is clamped; and a single-stream flow + produces byte-identical marking, `resumeAt`, status and step ordering to + the pre-change engine — the assertion that carries the BREAKING + `flow-engine` delta. +- [ ] 8.3 Recovery, status and migration: a claim whose holder died is released + after the cutoff, its branch failed and named, never re-dispatched; a live + long-running firing is not reaped; a branch waiting days on a signal is + not failed as abandoned by `findStale()` + (`lib/Db/FlowRunMapper.php:543-547`); one branch waiting and one working + reads as `running`; and the migration back-fill over a seeded database + with in-flight multi-token runs, applied twice with identical results. + +## Acceptance criteria + +- Two branches of one run that share no place advance at the same time, and a + branch waiting on a human answer, a timer or a remote call holds up no + sibling. Measured against the failing case in proposal.md, not asserted. +- Token loss is unrepresentable, not rare. No code path writes the marking from + a value read outside the run-row lock: a grep for `setMarking(` returns only + delta callers, and the whole-value write at + `lib/Service/Flow/FlowRunMarkingStore.php:102-105` no longer exists. +- No two firings whose place sets intersect are ever both dispatched. Every + dispatch is preceded by a committed claim on every place it touches, and no + claim attempt ever waits. +- A run's canonical log is a function of the path taken. Two runs of one flow + over the same path produce identical `(ordinal_path, sequence)` sequences + whatever the timing, and no row's position comes from a run-wide counter. +- The run status set still has exactly seven values, and `findQueued()`, + `findDue()`, `findStale()` and `hasActiveRun()` are unchanged queries that + return the same runs they would have returned before. +- The stream cap reads `FlowConcurrency::DEFAULT_LIMIT` and `MAX_LIMIT` + directly. No second pair of numbers exists anywhere in the change. +- The claim reaper uses the SAME cutoff expression as `reapStale()`. A grep + finds one occurrence of that expression, not two. +- A run's `firings` never exceeds `MAX_TRANSITIONS`, across all streams and all + passes, and reaching it fails the run with the existing message. +- An oversight refusal stops every branch of the run, and no branch begins a + firing after one has been raised. +- A single-stream flow behaves identically to today — same marking, same + `resumeAt`, same status, same step order — which is what makes the + `flow-engine` spec change safe for existing flows. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- New PHP files carry `@license EUPL-1.2` and `@copyright 2026 Conduction B.V.` +- `@spec` annotations point at + `openspec/specs/flow-parallel-streams/spec.md` anchors. +- References ADR-098 Decision 7 (streams run simultaneously) and Decision 9 + (advance budgets), ADR-065 (one engine), ADR-031 (the imperative engine-core + path is argued in design.md, not assumed). +- Depends on `flow-definition-versioning`: every stream of one run resolves the + SAME pinned definition. Assert it — two streams of one run walking two + graphs is the failure this dependency exists to prevent. +- SQLite's lack of row-level locking is noted in the test suite so a green + SQLite run is not read as evidence the locking works. diff --git a/openspec/changes/flow-task-entity/.openspec.yaml b/openspec/changes/flow-task-entity/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/flow-task-entity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/flow-task-entity/design.md b/openspec/changes/flow-task-entity/design.md new file mode 100644 index 0000000000..4dc49bfa66 --- /dev/null +++ b/openspec/changes/flow-task-entity/design.md @@ -0,0 +1,448 @@ +# Design: flow-task-entity + +## Context + +See proposal.md — Why. The measured starting point: + +- `AwaitSignalNode` is the engine's only human step. Its `assignee` config + is free text and its own help string says it "does not by itself restrict + who may answer" (`lib/Service/Flow/Nodes/AwaitSignalNode.php:200-203`). +- The answer lands in `$run->getContext()['signal']` + (`lib/Service/Flow/FlowRunService.php:521-537`) — a JSON column on the + run. There is no row per person, so no list, no count, no pool. +- `POST /api/flow-runs/{uuid}/resume` (`appinfo/routes.php:1312`) is + `#[NoAdminRequired]` and its only guard is `refuseUnlessRunnable()` on the + FLOW (`lib/Controller/FlowRunController.php:423-436`). +- The nearest existing task-shaped entity is `ApprovalStep` + (`lib/Db/ApprovalStep.php`, 208L): uuid, chain_id, object_uuid, + step_order, role, status, decided_by, comment, decided_at, created, + requester_id. It has the right skeleton — a unit of owed work anchored to + an object with a decision and an audit-relevant requester — and is + missing everything that makes it fleet-generic. +- A DIFFERENT `TaskService` already exists at `lib/Service/TaskService.php` + (753L): the CalDAV VTODO integration leaf serving nc-vue's `tasks` leaf. + It is not touched here; the new service is namespaced + `lib/Service/Task/` to keep the two apart by path, not by hope. + +Constraint from the chain: `flow-definition-versioning` lands first. A task +that stores `node_id` is storing a pointer into a definition; without a +pinned version that pointer's meaning can change under a live task. + +## Goals / Non-Goals + +**Goals:** +- One durable task record that all 23 inventoried fleet shapes can migrate + onto without a per-app column. +- A service where authorization is structurally unavoidable — not a check a + caller may forget. +- An inbox query that is cheap enough to power a badge count on every page + load. +- Storage decisions that survive the migration wave: adding the nineteenth + consuming entity kind must not need a migration. + +**Non-Goals (design-level, on top of the proposal's scope):** +- No performance work on the existing flow run tables. Tasks join to runs by + `run_uuid`; the run tables are unchanged. +- No API for editing a task's `metadata` field-by-field. It is a carried + blob, written whole. +- No admin UI. The inbox is an API in this change; the Vue surface arrives + with `flow-task-inbox-projections`. +- No attempt to reconcile the fleet's SIX status enums inside the consuming + apps. This change publishes the mapping; the apps adopt it in + `flow-approval-consolidation`. + +## Decisions + +### D-1 — Declarative-vs-imperative decision (ADR-031) + +**The task's LIFECYCLE and AUTHORIZATION are imperative by necessity; its +DELIVERY and its DERIVED FIELDS stay declarative. Both halves are justified +below, and the imperative half is deliberately fenced.** + +ADR-031's default path is: when an `x-openregister-*` schema extension +expresses the requirement, declare it rather than write a service. Applying +that test field by field: + +**Imperative — the entity, the lifecycle and the authorization.** +`x-openregister-lifecycle` operates on OR OBJECTS: it evaluates transitions +and guards over object state stored in the object store. A task is not an +object, and ADR-098 D2 rejected making it one. The reasons are measurable +rather than stylistic: + +1. **Pooled-inbox queries.** "Every unclaimed task in any group I am in, + with its subject's title, ordered by due date, page 1 of 5, plus a + total" is a join with indexed predicates on assignee, candidate group, + state and `due_at`. Over the object store these are JSON-extract + predicates over a generic table — the same shape that makes + `getAllUserTasks()` (`lib/Service/TaskService.php:120`) walk calendars. + An inbox badge is rendered on every page; it has to be an index hit. +2. **Atomic claim.** `claim` must be a conditional update — assign IF still + unassigned — so two clicks produce one assignee and one conflict. The + object write path is a read-modify-write over a serialized document; + last write wins is exactly the wrong semantics for a claim. +3. **Fail-closed authorization on a verb.** The check must run before the + mutation and must DENY on "cannot determine". A schema guard evaluates + over object data; it cannot express "resolve this role against the group + backend and refuse if the backend is unavailable". + +So: a native table + `TaskService`, in the same category ADR-031 preserves +for PHP (engine mechanics, external identity resolution, concurrency +control) — not a business rule that a schema could have carried. + +**Declarative — everything that CAN be.** Nothing in this change writes a +notification. Delivery is `x-openregister-notifications` (ADR-031) on the +task projection, specified in `flow-task-inbox-projections`, addressing the +NAMED TRANSITION ACTIONS this change records (`transition(action)` triggers) +— which is exactly why the action name is stored alongside the resulting +state rather than being derivable from it. Escalation rules are declarative +too and belong to `flow-business-timers`. + +**Derived, never stored.** `overdue`, `days_until_due` and `days_overdue` +are computed projections in the ADR-031 calculated-field spirit. The +counter-example is live: three fleet schemas store `overdue` as a status +value, and decidesk's `actionOverdue` notification filters +`taskStatus: 'overdue'`, so it only ever fires for tasks something +remembered to stamp. A stored clock-derived field is a field that is wrong +between writes. The same reasoning gives `is_terminal` the opposite +treatment — it is materialised because it is derived from the STATE, which +only changes on a write we control, not from the clock, which changes +constantly. + +The imperative half is fenced by one rule: `TaskService` may not contain a +business rule about what a specific app's task means. Every branch in it +must be about lifecycle, identity or concurrency. + +### D-2 — Native table, not OR objects, not VTODO-as-store + +Three storage options were evaluated (ADR-098 D2 records the outcome; the +reasoning is restated here because it drives the whole schema): + +1. **OR objects with a `task` schema.** Rejected. Every consuming app would + get the pooled-inbox query cost of D-1(1), and — worse — a task's + lifecycle would be writable through the generic object API, so "who may + move this task" would be an object ACL question rather than a performer + question. Half the fleet's shapes are OR objects today and that is + precisely why none of them can enforce who completes them. +2. **VTODO as the store.** Rejected on four counts, each independently + fatal: ICS blobs make pooled queries a parse-per-row; a VTODO lives in + ONE calendar, so a candidate-group pool has no home; DAV objects are + user-editable, so lifecycle state would be untrusted input; and DAV + writes are whole-object PUTs, so a claim is racy by construction. +3. **A native table** (chosen). The projections we want from VTODO — a task + appearing in the user's calendar — are a PROJECTION, built in + `flow-task-inbox-projections` with an authorizing write-back listener. + Projection is cheap; storage is not reversible. + +`ApprovalStep` is the shape's ancestor, not its implementation: it is +NOT extended in place, because a chain step's `step_order`/`chain_id` are +chain semantics that a standalone task must not carry. `ApprovalStep` +retires in `flow-approval-consolidation`. + +### D-3 — run_uuid/node_id are provenance, not identity + +The single most consequential shape decision. If `run_uuid` were required, +every consuming app would need a flow to have a task, and the 23-shape +migration would become "rewrite 23 apps as flows first" — a programme that +never finishes. Making the columns nullable costs one index and buys the +migration order: shapes move onto the entity first, and onto flows later, +independently. + +The rule this creates and the spec enforces: nothing derived from a run may +be load-bearing for a task with no run. Cancellation propagation is the +place this bites, so the spec states it explicitly — a task with +`run_uuid` null is never terminated by propagation. + +### D-4 — Two deadline columns, one enforced + +`due_at` advises, `expires_at` enforces. Merging them into one column plus a +boolean was considered and rejected: a boolean makes the DEFAULT the +dangerous case. Of 23 shapes exactly one (openconnector's +`approval_request`) auto-transitions on a deadline; with a merged column, +any migration that got the flag wrong would arm auto-termination on an +advisory date, and the failure mode is a silently cancelled approval. Two +columns make the enforcing case require an explicit act of writing to the +enforcing column. + +`start_at`, the SLA triple `{value, unit}`, `compliance_period`, +suspendability and `recurrence` are STORED here (columns exist, values +round-trip) and INTERPRETED in `flow-business-timers`. Storing them here +avoids a second migration on the same table two changes later; not +interpreting them here keeps the clock logic in one place. + +### D-5 — Performer as (type, ref, pool, strategy), not as columns per app + +The union needs: uid; uid + group (pipelinq alone separates +`assigneeUserId` from `assigneeGroupId`); group-only pool +(openconnector `approverGroup`); role name (`lib/Db/ApprovalStep.php:81-87` +stores `role`); five routing strategies; and delegation. Modelled as: + +- `performer_type` ∈ `user|group|agent|worker` (ADR-098 D3), +- `assignee` — the resolved current holder, empty while pooled, +- `candidate_users` / `candidate_groups` / `candidate_role` — the pool + before resolution, +- `routing_strategy` ∈ `single-role|or-set|hierarchical|round-robin| + least-loaded` + `routing_fallback`, +- `on_behalf_of` + `mandate` for delegation. + +`agent` and `worker` are not a special path: an agent claims and completes +through the same verbs, which is what makes the Camunda external-task +generalisation in ADR-098 D3 real rather than aspirational. The audit +records the performer type so that "a human approved this" and "a model +approved this" are distinguishable after the fact — which is the whole +point of writing it down. + +A strategy resolving to nobody leaves the task pooled. The tempting +fallbacks (assign to requester; assign to the first pool member; assign to +a system identity) each turn a routing misconfiguration into a silently +answerable task. + +### D-6 — One anchor plus a typed relation table + +`object_uuid` + `register_id` + `schema_id` is the anchor; +`openregister_task_relations` (task_id, role, object_uuid, register_id, +schema_id) carries everything else. The inventory found at least eighteen +distinct anchored entity kinds. Twenty FK columns would mean the table's +width is a function of how many apps have migrated, and every new consumer +would ship a migration. The relation table absorbs the nineteenth kind with +an INSERT. + +The cost is one join for "tasks related to this contract". It is indexed on +(object_uuid, role) and it is not the inbox's hot path, which uses the +anchor columns directly. + +### D-7 — Legacy values are mapped at the boundary and refused when unknown + +One published mapping table from the fleet's six status vocabularies onto +the six CMMN states, applied by every writer. Two rules make it safe: + +- Values that collapse (`done`/`completed`, `cancelled`/`terminated`, the + four spellings of in-progress) land on the same state, and the + distinction they carried moves to `outcome`. Nothing is lost, but nothing + stays in the state column that does not belong there. +- An unrecognised value is REFUSED. A coercing default would have absorbed + `procest/lib/Service/Transitions/CreateTaskHandler.php:76` writing + `status:'open'` into an enum without `open`, and absorbed pipelinq's + `task.priority` default `"normaal"` against the enum + `["low","normal","high"]` — both of which are bugs that should surface + during migration, loudly, once. + +### D-8 — Cancellation propagation is a listener on run terminality + +Tasks are terminated by a listener on the run reaching a terminal status +(`completed`, `stopped`, `dead_letter`, `failed` — `lib/Db/FlowRun.php` +STATUS constants), and by an explicit service call when a branch decision +makes a task moot. Deleting the task instead was rejected: the audit must +survive, and "why did this disappear from my inbox" must be answerable. + +Termination is idempotent and skips already-terminal tasks, because run +terminality can be observed more than once (the stale-run reaper in +`lib/Cron/FlowRunWorker.php` runs on a 15-minute cadence and can race a +completing run). + +### D-9 — The inbox filters and paginates in the datastore + +Non-negotiable, and stated in the spec as a requirement rather than left to +implementation: a client-side filter over a server-paginated result drops +rows the current page did not contain, reports a wrong total, and looks +like success. Visibility is part of the WHERE clause for the same reason — +filtering a wider result afterwards makes the total leak the existence of +tasks the caller may not see. + +## Data model + +`openregister_tasks` — grouped by concern, all columns nullable unless +stated: + +| Group | Columns | +|---|---| +| Identity | `id` (PK), `uuid` (NOT NULL, unique), `key` (external ref), `title`, `description`, `metadata` (JSON) | +| Provenance | `run_uuid`, `node_id`, `app_id`, `workflow_step_id`, `organisation` | +| Lifecycle | `state` (NOT NULL, CMMN six), `is_terminal` (NOT NULL bool), `last_action`, `outcome`, `blocked_reason` | +| Performer | `performer_type` (NOT NULL), `assignee`, `candidate_users` (JSON), `candidate_groups` (JSON), `candidate_role`, `routing_strategy`, `routing_fallback`, `on_behalf_of`, `mandate`, `requester`, `watchers` (JSON) | +| Timing | `start_at`, `due_at`, `expires_at`, `sla_value`, `sla_unit`, `compliance_period_days`, `suspended_until`, `recurrence` | +| Priority | `priority` (NOT NULL, `low|normal|high|urgent`) | +| Anchor | `object_uuid`, `register_id`, `schema_id` | +| Template | `template_id`, `template_version`, `template_snapshot` (JSON) | +| Checklist | `checklist` (JSON array of `{id,label,description,checked}`), `responses` (JSON append-only), `percent_complete` | +| Completion | `completed_at`, `completed_by`, `result_text`, `comment`, `evidence` (JSON file refs), `override_reason` | +| Hierarchy | `parent_task_id`, `epic_task_id` | +| Audit stamps | `created` (NOT NULL), `updated`, `created_by` | + +No `overdue`. No `days_until_due`. No `days_overdue`. Two hierarchy columns +because planix genuinely has two (subtask parent and epic) and overloading +one `parent` is what makes its two hierarchies indistinguishable today. + +Indexes: `(assignee, is_terminal, due_at)` for "my open work"; +`(is_terminal, due_at)` for the overdue sweep; `(object_uuid)` for "tasks +on this object"; `(run_uuid)` for propagation; unique on `uuid`. + +Candidate pools are stored twice on purpose: the `candidate_users` / +`candidate_groups` JSON columns are the readable record, and a companion +index table `openregister_task_candidates` (task_id, kind, ref, index on +`(kind, ref)`) is what the pooled inbox joins against — so "unclaimed tasks +in any group I am in" is an index hit rather than a JSON scan per row. The +drift risk this creates is named in Risks and is mitigated by one write +path maintaining both inside the transaction. + +`openregister_task_relations`: `id`, `task_id`, `role`, `object_uuid`, +`register_id`, `schema_id`, index on `(object_uuid, role)`. + +`openregister_task_audit`: `id`, `task_id`, `action`, `state_after`, +`actor`, `performer_type`, `on_behalf_of`, `mandate`, `reason`, +`authorized` (bool — denials are recorded too), `created`. Append-only: no +UPDATE or DELETE path exists, and the task's own deletion does not cascade +to it. + +## Seed Data (ADR-001) + +Fixtures for PHPUnit and for a demo instance, spanning three organisation +archetypes so the entity is exercised beyond the approval case. All UUIDs +are nil placeholders; all uids are obviously fake. + +**1. Municipality — a pooled permit check, no flow attached.** +Exercises: `performer_type: group`, unclaimed pool, advisory `due_at`, an +anchor to a case object, `run_uuid` null. + +```json +{ + "uuid": "00000000-0000-0000-0000-000000000001", + "title": "Controleer bouwtekening op welstandseisen", + "state": "enabled", + "is_terminal": false, + "performer_type": "group", + "assignee": null, + "candidate_groups": ["GEMEENTE_VERGUNNINGEN_TEAM"], + "routing_strategy": "least-loaded", + "priority": "normal", + "due_at": "2026-09-04T17:00:00+02:00", + "expires_at": null, + "run_uuid": null, + "object_uuid": "00000000-0000-0000-0000-0000000000aa", + "register_id": 1, + "schema_id": 1, + "requester": "EXAMPLE_BALIE_USER" +} +``` + +**2. Consultancy — a delegated approval with enforcing expiry, on a run.** +Exercises: `performer_type: user`, delegation (`on_behalf_of` + `mandate`), +`expires_at` set, `run_uuid`/`node_id` provenance, comment-mandatory reject +path, a template snapshot. + +```json +{ + "uuid": "00000000-0000-0000-0000-000000000002", + "title": "Keur inkooporder > EUR 10.000 goed", + "state": "active", + "is_terminal": false, + "performer_type": "user", + "assignee": "EXAMPLE_DELEGATE_USER", + "on_behalf_of": "EXAMPLE_DIRECTOR_USER", + "mandate": "Volmacht inkoop 2026 — art. 4 lid 2", + "priority": "high", + "due_at": "2026-08-29T12:00:00+02:00", + "expires_at": "2026-09-01T12:00:00+02:00", + "run_uuid": "00000000-0000-0000-0000-0000000000f1", + "node_id": "approve-purchase-order", + "template_id": "00000000-0000-0000-0000-0000000000e0", + "template_version": 3, + "template_snapshot": { "checklist": [] }, + "object_uuid": "00000000-0000-0000-0000-0000000000bb", + "requester": "EXAMPLE_CONTROLLER_USER" +} +``` + +**3. Travel agency — an agent task with a checklist and a relation.** +Exercises: `performer_type: agent`, typed checklist array, an epic parent, +a typed relation to a second object, priority normalised from an iCal +integer on import. + +```json +{ + "uuid": "00000000-0000-0000-0000-000000000003", + "title": "Verifieer visumvereisten voor reisgroep", + "state": "active", + "is_terminal": false, + "performer_type": "agent", + "assignee": "EXAMPLE_AGENT_IDENTITY", + "priority": "urgent", + "checklist": [ + { "id": "c1", "label": "Paspoortgeldigheid > 6 maanden", "description": null, "checked": true }, + { "id": "c2", "label": "Visumplicht per bestemming gecontroleerd", "description": null, "checked": false }, + { "id": "c3", "label": "Transitvisum nodig?", "description": null, "checked": false } + ], + "epic_task_id": null, + "object_uuid": "00000000-0000-0000-0000-0000000000cc", + "run_uuid": null, + "requester": "EXAMPLE_TRAVEL_PLANNER" +} +``` + +**4. Two terminal tasks** — one `completed` with `outcome: "approved"` and +one `terminated` with a propagation reason naming a stopped run — so +`is_terminal`, outcome-preserved-through-collapse (D-7) and cancellation +propagation (D-8) have fixtures, and so the inbox has rows it must NOT +return as actionable. + +**5. One audit fixture per task**, including one DENIED entry +(`authorized: false`) so the append-only denial path is covered by seed data +rather than only by a test double. + +The seeds install through the existing seeding path used for OR's other +non-object fixtures and are idempotent on uuid. + +## Migration Plan + +1. Migration creates `openregister_tasks`, `openregister_task_candidates`, + `openregister_task_relations`, `openregister_task_audit` with their + indexes. Additive only — no existing table is altered, so rollback is a + table drop and nothing else regresses. +2. `ApprovalChain`/`ApprovalStep` and `lib/Service/ApprovalService.php` + stay live and untouched. They retire in `flow-approval-consolidation`; + running both for one release is deliberate, and no data is copied here. +3. No data backfill in this change. The tables ship empty apart from seeds. +4. Rollback: drop the four tables. Because nothing else reads them yet, + there is no dependent state to unwind — which is the reason this change + is scoped to "the target exists" and migration is a later change. + +## Risks / Trade-offs + +- **A wide table (≈45 columns) invites "just one more column" per consuming + app** → The fence is D-6 (relations, not FK columns) and the `metadata` + blob for genuinely app-private fields, with the spec's rule that + `metadata` is never read by lifecycle, authorization or inbox logic. A PR + adding a column named after an app is the signal to push back. +- **Two `TaskService` classes in one codebase** (`lib/Service/TaskService.php` + = CalDAV VTODO leaf, 753L; `lib/Service/Task/TaskService.php` = this) → + Namespaced by directory and both docblocks state which is which. The VTODO + leaf becomes a projection consumer in `flow-task-inbox-projections`; it is + not renamed here to keep this change free of unrelated churn. +- **Storing SLA/recurrence/suspension columns that nothing interprets yet** + → Accepted deliberately (D-4): the alternative is a second migration on + the same table two changes later. The risk is a column that ships wrong + and is only discovered when `flow-business-timers` reads it; mitigated by + round-trip tests on every stored-but-uninterpreted column. +- **`is_terminal` is denormalised and can drift from `state`** → It is + written only by the one lifecycle method that writes `state`, in the same + statement, and a test asserts the invariant across every transition in + the mapping table. +- **The candidate index table can drift from the `candidate_*` JSON** → + Same mitigation shape: one write path maintains both inside the + transaction; a test asserts pool membership queries and JSON agree after + every assignment verb. +- **Refusing unknown legacy status values will fail migrations loudly** + (procest's `'open'`, pipelinq's `"normaal"`) → Intended (D-7). The + mitigation is that these are already filed as defects against the owning + apps, not fixed silently inside this change. + +## Open Questions + +- Whether `openregister_task_candidates` should also index resolved role + members (materialised) or resolve roles at query time. Resolution can be + deferred: it is an index-population question that changes neither the + spec, the table shape, nor the task breakdown, and the answer depends on + role-backend latency measured after the inbox exists. +- The exact retention policy for `openregister_task_audit`. It is + append-only either way; how long it is kept is an operations setting that + can land with `flow-approval-consolidation`, when there is real volume to + size it against. diff --git a/openspec/changes/flow-task-entity/proposal.md b/openspec/changes/flow-task-entity/proposal.md new file mode 100644 index 0000000000..8d39773596 --- /dev/null +++ b/openspec/changes/flow-task-entity/proposal.md @@ -0,0 +1,164 @@ +--- +kind: code +depends_on: [flow-definition-versioning] +--- + +# Proposal: flow-task-entity + +## Summary + +Give the fleet ONE task: a native `openregister_tasks` table, a +`TaskService` whose every lifecycle verb is fail-closed authorized, and a +queryable inbox that joins a task to the object it is about. The task is +**fleet-generic** — `run_uuid`/`node_id` are optional provenance, not +identity, so a standalone task with no flow attached is first-class. This +change delivers the entity, the service, the authorization and the inbox +query. Nothing that stands on top of them (the `user-task` node, forms, +projections, timers, migrations) is in it. + +## Why + +**A human step in OR Flow today has no owner.** `AwaitSignalNode` takes an +`assignee`, and its own config form says what it is worth: +"A user or group id. Recorded with the request; it does not by itself +restrict who may answer." +(`lib/Service/Flow/Nodes/AwaitSignalNode.php:200-203`). The endpoint behind +it, `POST /api/flow-runs/{uuid}/resume` (`appinfo/routes.php:1312`), is +`#[NoAdminRequired]` and guards only that the FLOW is runnable +(`lib/Controller/FlowRunController.php:423-436`) — never that the CALLER is +the person being waited on. Any authenticated user who knows a run uuid can +approve anyone's case. That is not a hardening backlog item; it is the +reason a human step cannot be trusted with an approval. + +**And there is no inbox.** The signal is written into +`$run->getContext()['signal']` (`FlowRunService.php:521-537`). There is no +row that says "this person owes an answer", so there is nothing to list, +nothing to count, nothing to pool across a group, and nothing to chase. +"What is waiting for me?" is answerable only by scanning suspended runs and +reading free text out of a JSON column. + +**Meanwhile the fleet has built the same task 23 times.** The inventory ran +2026-08-22: 23 task shapes across procest, pipelinq, planix, decidesk, +openbuild, shillinq, openconnector and OpenRegister itself. They do not +merely differ, they CONFLICT: + +- `status` is six incompatible enums — `done` vs `completed`, `cancelled` + vs `terminated`, and one single state spelled four ways + (`in_progress` / `in-progress` / `in-execution` / `active`). Even the + FIELD is renamed per app: `status` / `taskStatus` / `lifecycle` / + `instanceState` / `action`. +- `assignee` means three different things: a Nextcloud uid, a + non-binding recorded string, and a ROLE name + (`lib/Db/ApprovalStep.php:81-87` stores `role`, not a uid). +- Priority runs on four scales: `low|normal|high|urgent`, + `low|normal|high`, iCal integer 0-9, and `low|medium|high|critical`. +- The due date is `date-time` in some schemas and `date` in others, under + six names (`dueDate`/`deadline`/`dueAt`/`expiresAt`/`dueBefore`/ + `targetDate`) — and `due` (advisory) and `expires` (enforcing) are + conflated, though only openconnector actually auto-transitions on one. + +The cost is already being paid in bugs, not in tidiness: +`procest/lib/Service/Transitions/CreateTaskHandler.php:76` writes +`status:'open'` into a schema whose enum has no `open`, so every +flow-spawned procest task is out-of-enum. And **three fleet schemas store +`overdue` as a status value** — a clock-derived fact written by hand, so +decidesk's `actionOverdue` notification, which filters +`taskStatus:'overdue'`, fires only if something remembered to write it. + +## What Changes + +- **A native entity + table**, `OCA\OpenRegister\Db\Task` / + `openregister_tasks`. Shape-wise this is `ApprovalStep` + (`lib/Db/ApprovalStep.php`, 208L) given a nullable `run_uuid` + `node_id` + so it is addressable from a flow graph, plus the columns the 23-shape + union demands. It is NOT an OR object and NOT a VTODO: both were + evaluated and rejected (design.md records why — pooled-inbox query cost, + one-calendar ownership, user-editable lifecycle state, racy whole-object + PUT). +- **The union, resolved once**: nullable/synthesized `title` (4 shapes have + none); CMMN lifecycle `available|enabled|active|completed|terminated| + disabled` with a legacy-value mapping table and a materialised + `is_terminal`; a `performer_type` of `user|group|agent|worker` (ADR-098 + D3) spanning uid, uid+group, group-only candidate pool, role name, five + routing strategies and delegation (`on_behalf_of` + `mandate`); + `due_at` (advisory) and `expires_at` (enforcing) as **separate columns**; + one normalised priority; a typed `checklist` array replacing procest's + JSON-in-a-STRING; **one** generic anchor (`object_uuid` + + `register_id`/`schema_id`) plus a typed relation table, NOT 20 FK + columns; completion metadata with comment-mandatory-on-reject; and + `template_id` + `template_version` + a frozen `template_snapshot`. +- **`overdue` is DERIVED, never stored.** No column, no enum value. It is a + computed projection of `due_at`/`expires_at` against now, so it cannot go + stale and cannot be forgotten. +- **`TaskService`** with create / offer / claim / unclaim / assign / + reassign / delegate / resolve / complete / cancel — each one authorized + fail-closed against the performer model before it mutates anything, and + each one appending to an immutable task audit that records the actor AND + the performer type. +- **Cancellation propagation**: when a run reaches a terminal status, or a + branch decision makes a pending task moot, its tasks are terminated with + a reason. Orphaned inbox entries are the classic retrofit bug; this is + built in, not added later. +- **A queryable inbox API**: "my tasks", "my group's unclaimed tasks", + "tasks on this object", each joined to the subject object so a row + carries case context — the exact thing `AwaitSignalNode` provably lacks. + +## What does NOT change + +Each of these is a separate change in the ADR-098 chain and is explicitly +OUT of scope here: + +- **`flow-user-task-node`** — the `openregister.user-task` node, the + suspend/resume wiring, and the `advance` budget (ADR-098 D9). This change + gives the node something to create; it does not create the node. +- **`flow-task-forms`** — structured completion payloads over the + lifecycle transition `inputs` contract + (`lib/Service/Lifecycle/TransitionEngine.php:675-704`) and the nc-vue + form family. Task completion here takes a typed but hand-specified + payload. +- **`flow-task-inbox-projections`** — `INotificationManager` notifications + and the CalDAV VTODO projection with the authorizing write-back listener. + No task in this change notifies anybody or appears in a calendar. +- **`flow-business-timers`** — SLA, business-day arithmetic, escalation + matrices, breach sweeps. `due_at`/`expires_at` are STORED here and acted + on there. +- **`flow-approval-consolidation`** and the per-app changes — migrating the + 23 shapes, retiring `ApprovalService`, procest CMMN, openconnector HITL. + Nothing is migrated in this change; the target simply comes into + existence. + +`AwaitSignalNode` also stays exactly as it is. It is superseded by +`flow-user-task-node`, not by this change. + +## Capabilities + +### New Capabilities +- `flow-tasks`: the fleet-generic task entity, its authorized lifecycle + service, its append-only audit, and the inbox query surface. + +### Modified Capabilities + + +## Impact + +- **Affected specs**: new `flow-tasks`. `flow-engine` untouched — this + change adds no node and changes no run semantics. +- **Affected code**: new `lib/Db/Task.php`, `TaskMapper.php`, + `TaskRelation.php`, `TaskRelationMapper.php`, `TaskAudit.php`, + `TaskAuditMapper.php`; new `lib/Service/Task/TaskService.php` + + `TaskAuthorizationService.php` + `TaskInboxService.php`; new + `lib/Controller/TaskController.php` + routes; one migration. + `lib/Service/TaskService.php` (753L, the CalDAV VTODO leaf) is a + DIFFERENT thing and is not touched — the naming collision is resolved by + namespacing the new one under `lib/Service/Task/`. +- **Affected apps**: none yet, by design. Consumers arrive with + `flow-approval-consolidation`. +- **Depends on**: `flow-definition-versioning` — a task pinned to + `run_uuid`/`node_id` is only meaningful if the run's definition version is + pinned too; otherwise a task outlives the node that created it and points + at a node id that has since changed meaning. +- **ADRs**: ADR-098 D2 (native entity, not objects, not VTODO-as-store), + D3 (performer `user|group|agent|worker`), D4 (CMMN lifecycle leads), + D6 (versioning first); ADR-031 (declarative-vs-imperative — see design.md); + ADR-001 (seed data); ADR-005 (fail-closed authorization); ADR-065 (one + engine). diff --git a/openspec/changes/flow-task-entity/specs/flow-tasks/spec.md b/openspec/changes/flow-task-entity/specs/flow-tasks/spec.md new file mode 100644 index 0000000000..7d70acbacc --- /dev/null +++ b/openspec/changes/flow-task-entity/specs/flow-tasks/spec.md @@ -0,0 +1,503 @@ +## Purpose + +One fleet-generic human-or-agent task: a durable record of work owed by a +performer, its authorized lifecycle, its append-only audit, and the inbox +query that answers "what is waiting for me?". A task may be attached to a +flow run, but a standalone task is equally first-class. + +## ADDED Requirements + +### Requirement: A task is a first-class record, not a flow artefact + +The system SHALL persist a task as a native record with its own identity, +independent of any flow run. + +`run_uuid` and `node_id` SHALL be OPTIONAL and SHALL carry provenance only. +A task created with neither SHALL be valid, listable, claimable, +completable and auditable by every rule in this capability — no code path +may treat "no run" as a degraded or unsupported case. + +A task SHALL be addressable by uuid. `title` SHALL be nullable: of the 23 +fleet task shapes inventoried on 2026-08-22, four (`approval_request`, +`ApprovalStep`, `parafeeractie`, `handhavingsactie`) carry no title at all. +When `title` is null the system SHALL synthesize a display title from the +task's action and its subject object, and SHALL NOT persist the synthesized +value — a synthesized title that is stored becomes stale the moment the +subject is renamed. + +A task SHALL carry an unstructured `metadata` map for fields a migrating +app needs to preserve and this capability does not model. `metadata` SHALL +NOT be readable by any lifecycle, authorization or inbox rule: it is +carried, not interpreted. + +#### Scenario: A task with no run behaves identically to one with a run + +- **GIVEN** two otherwise identical tasks, one carrying a `run_uuid` and one + with `run_uuid` null +- **WHEN** each is offered, claimed, and completed by the same performer +- **THEN** both MUST succeed with the same lifecycle states and the same + audit entries +- **AND** neither MUST require a flow definition to exist +- @e2e exclude covered by TaskService unit tests over both shapes + +#### Scenario: A titleless task still displays + +- **GIVEN** a task with `title` null anchored to a subject object +- **WHEN** it is returned from the inbox +- **THEN** the response MUST carry a non-empty display title derived from + the task action and the subject +- **AND** the stored `title` MUST still be null +- @e2e exclude covered by unit tests on title synthesis + +### Requirement: One lifecycle, with every legacy value mapped onto it + +A task's state SHALL be one of the CMMN plan-item states `available`, +`enabled`, `active`, `completed`, `terminated`, `disabled` (ADR-098 D4). +No other value SHALL be persistable. + +The system SHALL publish a mapping from the legacy vocabularies in use +across the fleet onto these six, and every migration and every API that +accepts a legacy value SHALL resolve it through that one mapping. The +mapping SHALL cover at minimum: `open`, `pending`, `todo`, `blocked`, +`in_progress`, `in-progress`, `in-execution`, `done`, `resolved`, +`approved`, `rejected`, `waived`, `skipped`, `cancelled`, `expired`, +`error`, `dead_letter`, `reopen`. Values that collapse onto one state +(`done`/`completed`; `cancelled`/`terminated`; the four spellings of +in-progress) SHALL resolve to the SAME state — the distinction they carried +SHALL survive on `outcome`, never on state. + +The system SHALL materialise a boolean `is_terminal` alongside the state so +that "is anything still open on this object?" is one indexed predicate and +not a set-membership test that each caller re-derives. + +Every state change SHALL be effected by a NAMED transition action (for +example `claim`, `complete`, `reject`, `cancel`). The action name SHALL be +recorded, because downstream notification rules (ADR-031 +`transition(action)` triggers) address the action, not the resulting state. + +An unrecognised legacy value SHALL be REJECTED with an error naming the +value. It SHALL NOT be silently coerced to a default state. + +#### Scenario: Two legacy spellings converge without losing the distinction + +- **GIVEN** one task imported with a legacy status `done` and one with + `approved` +- **WHEN** both are read back +- **THEN** both MUST report state `completed` with `is_terminal` true +- **AND** their `outcome` values MUST still differ +- @e2e exclude covered by the legacy-mapping unit test table + +#### Scenario: An unmapped status is refused, not defaulted + +- **GIVEN** an import carrying the status `'open'` against a vocabulary that + does not define it — the live defect at + `procest/lib/Service/Transitions/CreateTaskHandler.php:76` +- **WHEN** the task is written +- **THEN** the write MUST fail with an error naming the unmapped value +- **AND** no task record MUST be created +- @e2e exclude covered by TaskService validation unit tests + +### Requirement: Overdue is derived and MUST NOT be stored + +The system SHALL NOT provide any column, state value, or persisted field +that records whether a task is overdue. + +Overdue SHALL be computed at read time by comparing `due_at` (and, where +set, `expires_at`) against the current time. The same computation SHALL +back the inbox filter, the API projection, and any notification recipient +query — one derivation, no second opinion. + +The reason is measured: three fleet schemas store `overdue` as a status +value, and decidesk's `actionOverdue` notification filters +`taskStatus: 'overdue'`, so it fires only when something remembered to +write that value. A clock-derived fact maintained by hand is a fact that is +wrong between writes. + +#### Scenario: A task becomes overdue with no write + +- **GIVEN** a task with `due_at` in the future and no writes performed on it +- **WHEN** the clock passes `due_at` +- **THEN** reading the task MUST report it as overdue +- **AND** the inbox overdue filter MUST return it +- **AND** the task's stored row MUST be byte-identical to before +- @e2e exclude covered by a clock-controlled unit test + +### Requirement: due_at advises, expires_at enforces + +`due_at` and `expires_at` SHALL be separate columns with separate meanings. + +`due_at` is ADVISORY: passing it changes what the task is reported as and +what may be notified about it, and SHALL NOT change its state. + +`expires_at` is ENFORCING: passing it makes the task eligible for automatic +transition to a terminal state. Of the 23 inventoried shapes, only +openconnector's `approval_request` carries enforcing expiry; conflating the +two would silently arm auto-termination on every advisory deadline in the +fleet. + +Setting `expires_at` earlier than `due_at` SHALL be rejected: a task that +dies before it is due is a configuration error, not a schedule. + +The ACT of enforcing expiry — the sweep, the business-day arithmetic, the +escalation matrix — is NOT part of this capability and is specified by +`flow-business-timers`. This capability specifies only that the two columns +exist, mean different things, and that nothing in it auto-transitions a +task on `due_at`. + +#### Scenario: A due date passing does not change state + +- **GIVEN** an `active` task whose `due_at` has passed and whose + `expires_at` is null +- **WHEN** it is read +- **THEN** its state MUST still be `active` +- **AND** it MUST be reported overdue +- @e2e exclude covered by a clock-controlled unit test + +#### Scenario: expires_at before due_at is refused + +- **GIVEN** a task write with `expires_at` earlier than `due_at` +- **WHEN** it is submitted +- **THEN** it MUST be rejected with an error naming both values +- @e2e exclude covered by TaskService validation unit tests + +### Requirement: The performer model spans people, groups, agents and workers + +Every task SHALL carry a `performer_type` of `user`, `group`, `agent` or +`worker` (ADR-098 D3) alongside the performer reference. An AI agent or an +external worker SHALL claim and complete a task through the SAME verbs and +the SAME authorization as a person; there SHALL be no agent-only or +worker-only completion path. + +The model SHALL express, without app-specific columns: +a Nextcloud uid; a uid together with a group (only pipelinq separates +`assigneeUserId` from `assigneeGroupId`); a group-only CANDIDATE POOL with +no assignee yet (openconnector `approverGroup`); and a ROLE name resolved +to people at authorization time — `lib/Db/ApprovalStep.php:81-87` stores a +role, not a uid, and today the word "assignee" means a uid, a +non-binding recorded string, and a role name in three different fleet apps. + +Assignment from a candidate pool SHALL support the routing strategies +`single-role`, `or-set`, `hierarchical`, `round-robin` and `least-loaded`, +plus a `fallback` performer used when a strategy resolves to nobody. A +strategy that resolves to nobody and has no fallback SHALL leave the task +unassigned in the pool and SHALL NOT assign it to the requester, the +system, or an arbitrary member. + +Delegation SHALL be first-class: a delegate acts with `on_behalf_of` +naming the original performer and a `mandate` recording the authority +relied on. The audit SHALL show both the acting identity and the +on-behalf-of identity; a delegated completion SHALL NOT be recorded as the +original performer acting. + +The task SHALL additionally carry a `requester` distinct from the +performer, and a `watchers` list that confers read visibility and no +lifecycle rights whatsoever. + +#### Scenario: A group task has no assignee until someone claims it + +- **GIVEN** a task with `performer_type` `group` and a candidate group +- **WHEN** it is created +- **THEN** its assignee MUST be empty +- **AND** it MUST appear in the unclaimed inbox of every group member +- @e2e exclude covered by TaskInboxService unit tests + +#### Scenario: An agent completes a task exactly as a person does + +- **GIVEN** a task with `performer_type` `agent` assigned to a registered + agent identity +- **WHEN** the agent completes it +- **THEN** the completion MUST pass the same authorization checks as a user + completion +- **AND** the audit entry MUST record performer type `agent` +- @e2e exclude covered by TaskService unit tests with an agent identity + +#### Scenario: A delegate's action names both identities + +- **GIVEN** a task assigned to one user and delegated to another with a + recorded mandate +- **WHEN** the delegate completes it +- **THEN** the audit MUST record the delegate as actor and the original + performer as on-behalf-of +- **AND** the mandate MUST be recorded on the audit entry +- @e2e exclude covered by delegation unit tests + +#### Scenario: A routing strategy that finds nobody assigns nobody + +- **GIVEN** a `least-loaded` strategy over a candidate group whose members + have all been filtered out, with no fallback configured +- **WHEN** assignment runs +- **THEN** the task MUST remain unassigned in the pool +- **AND** no implicit assignment to requester or system identity MUST occur +- @e2e exclude covered by routing-strategy unit tests + +### Requirement: Every lifecycle verb is authorized fail-closed + +The system SHALL expose the verbs `create`, `offer`, `claim`, `unclaim`, +`assign`, `reassign`, `delegate`, `resolve`, `complete` and `cancel`. + +Each verb SHALL evaluate authorization BEFORE any mutation, and SHALL DENY +when the answer cannot be determined — an unresolvable role, an +unavailable group backend, or an unknown performer type SHALL produce a +denial, never a skipped check. No verb SHALL be reachable by an +authenticated caller merely because they know the task's uuid. This closes +the hole measured at `lib/Controller/FlowRunController.php:423-436`, where +`resume` checks only that the flow is runnable and never that the caller is +the person being waited on. + +At minimum: `claim` SHALL require membership of the candidate pool; +`complete` and `resolve` SHALL require being the assignee, an authorized +delegate of the assignee, or an administrator; `reassign` and `cancel` +SHALL require the requester, an authorized supervisor, or an +administrator; `unclaim` SHALL require being the current assignee. + +`claim` SHALL be atomic: concurrent claims on one unassigned task SHALL +result in exactly one assignee, and the losing caller SHALL receive a +conflict, not a silent overwrite. + +A verb applied to a task already in a terminal state SHALL be refused with +a conflict naming the current state. + +Where a task's `outcome` is a rejection or a return, a non-empty `comment` +SHALL be MANDATORY and the verb SHALL be refused without one — openconnector +and procest's `parafeeractie` both require this today and both enforce it +in their own app code. + +#### Scenario: A stranger cannot complete someone else's task + +- **GIVEN** a task assigned to one user +- **WHEN** a different authenticated user who knows its uuid calls complete +- **THEN** the call MUST be denied +- **AND** the task state and assignee MUST be unchanged +- @e2e a stranger is refused on the task detail route + +#### Scenario: Two claims race and one loses + +- **GIVEN** an unassigned task in a candidate pool with two members +- **WHEN** both claim it concurrently +- **THEN** exactly one MUST become the assignee +- **AND** the other MUST receive a conflict response +- @e2e exclude covered by a concurrency unit test against the mapper + +#### Scenario: An unresolvable role denies rather than passes + +- **GIVEN** a task whose performer is a role name that the role resolver + cannot resolve +- **WHEN** any user attempts to complete it +- **THEN** the call MUST be denied with a reason naming the unresolvable + role +- **AND** the failure MUST NOT be reported as success or as "no check + applicable" +- @e2e exclude covered by TaskAuthorizationService unit tests + +#### Scenario: A rejection without a comment is refused + +- **GIVEN** a task being completed with a rejecting outcome and an empty + comment +- **WHEN** complete is called +- **THEN** it MUST be refused +- **AND** the task MUST remain in its pre-call state +- @e2e exclude covered by TaskService validation unit tests + +### Requirement: A task that has become moot is terminated, not orphaned + +When a flow run reaches a terminal status, every non-terminal task carrying +that `run_uuid` SHALL be transitioned to `terminated` with a reason naming +the run and its terminal status. + +When a branch decision makes a pending task unreachable — a competing +branch resolved a choice, or a stage the task belonged to closed — the task +SHALL likewise be terminated with a reason. + +Termination by propagation SHALL be recorded in the audit with the +propagation source as actor. It SHALL NOT be silently deleted and SHALL NOT +remain visible in any inbox as actionable work. + +A task with no `run_uuid` SHALL NEVER be terminated by propagation: nothing +about it is derived from a run. + +#### Scenario: Killing a run empties its inboxes + +- **GIVEN** a run with three tasks pending across two assignees +- **WHEN** the run is stopped +- **THEN** all three tasks MUST become `terminated` with a reason naming the + run +- **AND** neither assignee's inbox MUST list them as actionable +- @e2e exclude covered by cancellation-propagation unit tests + +#### Scenario: A standalone task survives everything + +- **GIVEN** a task with `run_uuid` null +- **WHEN** unrelated runs terminate +- **THEN** the task MUST remain in its current state +- @e2e exclude covered by cancellation-propagation unit tests + +### Requirement: The inbox answers "what is waiting for me?" in one query + +The system SHALL expose an inbox query supporting at minimum: tasks +assigned to the calling user; unclaimed tasks in the calling user's +candidate pools; tasks the caller watches; and tasks anchored to a given +object. + +Each returned row SHALL carry the subject object's identifying context +(register, schema, uuid and its display title) alongside the task, so a +list is readable without a second request per row. This is the capability +`AwaitSignalNode` provably lacks: its answer lives in +`$run->getContext()['signal']` (`lib/Service/Flow/FlowRunService.php:521-537`), +which is not listable, not countable and not poolable. + +The query SHALL support filtering by state, by `is_terminal`, by derived +overdue, by priority, and by anchor; and SHALL support sorting by `due_at`, +priority and creation time. It SHALL be paginated, and its result SHALL +carry a total so a badge count does not require fetching every row. + +Filtering and pagination SHALL be performed in the datastore. A +client-side filter applied over a server-paginated result SHALL NOT be +used, because it silently drops rows that the current page did not contain. + +The inbox SHALL return only tasks the caller may see: assignee, candidate +pool member, requester, watcher, or administrator. Visibility SHALL be +enforced in the query, not by filtering a wider result afterwards. + +#### Scenario: One request lists my work with case context + +- **GIVEN** a user assigned four tasks anchored to four different objects +- **WHEN** they request their inbox +- **THEN** the response MUST contain four rows +- **AND** each row MUST carry its subject object's register, schema, uuid + and display title +- @e2e the inbox route returns tasks with subject context + +#### Scenario: A pooled task is visible to the pool and to nobody else + +- **GIVEN** an unclaimed task in a candidate group +- **WHEN** a group member and a non-member each request their inbox +- **THEN** the member's response MUST include it +- **AND** the non-member's response MUST NOT include it, and MUST NOT + reveal its existence through the total +- @e2e exclude covered by TaskInboxService authorization unit tests + +#### Scenario: Filtering happens in the datastore + +- **GIVEN** 120 tasks matching a filter, with a page size of 25 +- **WHEN** the filtered inbox is requested +- **THEN** the first page MUST contain 25 matching rows +- **AND** the reported total MUST be 120 +- @e2e exclude covered by mapper-level pagination tests + +### Requirement: One generic anchor, plus typed relations + +A task SHALL anchor to its subject through ONE generic reference: +`object_uuid` together with `register_id` and `schema_id`. + +Additional related objects SHALL be recorded in a typed relation table +carrying the relation's ROLE (for example `subject`, `case`, `decision`, +`contract`, `evidence`). The 23 inventoried shapes anchor to at least +eighteen distinct entity kinds — case, client, ticket, project, column, +zaakUuid, decision, meeting, goal, phase, endpoint, rule, synchronization, +tenant, contract, clause, agendaItem, motion. Modelling those as columns +would produce a table that grows a column per consuming app; the relation +table absorbs the nineteenth without a migration. + +The anchor SHALL be optional: a task about nothing in particular is valid. + +#### Scenario: A new consuming entity kind needs no schema change + +- **GIVEN** a task anchored to an object of a schema this capability has + never seen +- **WHEN** it is created with a relation role of that schema's choosing +- **THEN** it MUST be stored and queryable by that role +- **AND** no column MUST have been added +- @e2e exclude covered by TaskRelationMapper unit tests + +### Requirement: A templated task freezes its template at creation + +Where a task is created from a template, the system SHALL record +`template_id`, `template_version`, and a `template_snapshot` holding the +template content as it stood at creation. + +All lifecycle evaluation, checklist rendering and completion validation for +that task SHALL read the SNAPSHOT, never the live template. Editing a +template SHALL NOT change any task already created from it. + +A task's checklist SHALL be a typed array of +`{id, label, description, checked}` entries. It SHALL NOT be a string +containing JSON — procest stores it that way today, which makes a checklist +unqueryable and its item state unaddressable. + +#### Scenario: Editing a template leaves running tasks alone + +- **GIVEN** a task created from a template with three checklist items +- **WHEN** the template is edited to have five +- **THEN** the existing task MUST still present three items +- **AND** its `template_version` MUST still name the version it was created + from +- @e2e exclude covered by template-snapshot unit tests + +#### Scenario: A checklist item is addressable + +- **GIVEN** a task with a three-item checklist +- **WHEN** one item is checked by its id +- **THEN** only that item's `checked` MUST change +- **AND** the change MUST appear in the task audit +- @e2e exclude covered by checklist unit tests + +### Requirement: The task audit is append-only and names the performer type + +Every lifecycle verb that succeeds, and every authorization denial, SHALL +append an audit entry recording: the task, the transition action, the +resulting state, the ACTING identity, the PERFORMER TYPE +(`user|group|agent|worker`), any `on_behalf_of` and `mandate`, the +timestamp, and the reason or comment where one was supplied. + +Audit entries SHALL NOT be updatable or deletable through any API. Deleting +a task SHALL NOT delete its audit entries. + +An audit entry SHALL be written in the same transaction as the mutation it +records, so a completed task without its audit entry is not a reachable +state. + +#### Scenario: A completion and its audit entry are inseparable + +- **GIVEN** a task being completed where the audit write fails +- **WHEN** the transaction resolves +- **THEN** the task MUST NOT be recorded as completed +- @e2e exclude covered by a transactional unit test with an injected audit + failure + +#### Scenario: A denial is auditable + +- **GIVEN** an unauthorized completion attempt +- **WHEN** it is denied +- **THEN** an audit entry MUST record the attempt, the acting identity and + the denial reason +- @e2e exclude covered by TaskAuthorizationService unit tests + +### Requirement: Priority is normalised to one scale on the way in + +A task's priority SHALL be one of `low`, `normal`, `high`, `urgent`. + +The system SHALL accept and normalise the fleet's other scales on write: +the three-value `low|normal|high`, the iCal integer range 0-9 used by the +CalDAV VTODO wire format, and the notification scale +`low|medium|high|critical`. Normalisation SHALL be a single published +mapping used by every caller. + +A value outside every known scale SHALL be rejected naming the value, not +coerced. pipelinq's `task.priority` today declares the enum +`["low","normal","high"]` with `"default": "normaal"` — a default that is +not in its own enum; a coercing normaliser would have hidden that for as +long as it existed. + +#### Scenario: An iCal integer arrives and lands on the scale + +- **GIVEN** a task written with priority `1` from the VTODO wire format +- **WHEN** it is read back +- **THEN** its priority MUST be `urgent` +- @e2e exclude covered by the priority-normalisation unit test table + +#### Scenario: An off-scale value is refused + +- **GIVEN** a task written with priority `"normaal"` +- **WHEN** the write is submitted +- **THEN** it MUST be rejected with an error naming the value +- @e2e exclude covered by the priority-normalisation unit test table diff --git a/openspec/changes/flow-task-entity/tasks.md b/openspec/changes/flow-task-entity/tasks.md new file mode 100644 index 0000000000..a7e8ac0da0 --- /dev/null +++ b/openspec/changes/flow-task-entity/tasks.md @@ -0,0 +1,165 @@ +# Tasks: flow-task-entity + +## 1. Storage + +- [ ] 1.1 Migration creating `openregister_tasks`, + `openregister_task_candidates`, `openregister_task_relations` and + `openregister_task_audit` with the columns and indexes in design.md — + Data model. Additive only: no existing table altered, no data + backfilled. Verify `openregister_tasks` has NO `overdue`, + `days_until_due` or `days_overdue` column. +- [ ] 1.2 Entities + mappers under `lib/Db/`: `Task`/`TaskMapper`, + `TaskCandidate`/`TaskCandidateMapper`, + `TaskRelation`/`TaskRelationMapper`, `TaskAudit`/`TaskAuditMapper`. + Follow `lib/Db/FlowRun.php` conventions (docblock `@method` block, + `@spec` tag, EUPL-1.2 header, `hydrate()` + `jsonSerialize()` as in + `lib/Db/ApprovalStep.php:154-206`). `TaskAuditMapper` exposes NO + update or delete method. + +## 2. Normalisation at the boundary + +- [ ] 2.1 Lifecycle: the six CMMN states, the published legacy→state + mapping covering at minimum the 18 values named in the spec, + `is_terminal` written in the same statement as `state`, and the + collapsed distinctions (`done`/`approved`, `cancelled`/`terminated`) + preserved on `outcome`. An unmapped value is REFUSED naming the + value — never coerced to a default. +- [ ] 2.2 Field normalisation and validation: priority across the four + fleet scales onto `low|normal|high|urgent` (off-scale refused, + naming the value); `expires_at` earlier than `due_at` refused; + `title` synthesis from action + subject computed on read and NEVER + persisted. + +## 3. Authorization (before any mutation) + +- [ ] 3.1 `lib/Service/Task/TaskAuthorizationService.php` — per-verb + decisions per the spec, evaluated before mutation, DENYING on + indeterminate (unresolvable role, unavailable group backend, unknown + performer type). No nullable "service unavailable" return that a + caller can read as "check skipped". +- [ ] 3.2 Performer resolution: `user|group|agent|worker`, candidate pool + (users / groups / role), and the five routing strategies + `single-role|or-set|hierarchical|round-robin|least-loaded` plus + `routing_fallback`. A strategy resolving to nobody with no fallback + leaves the task POOLED — no implicit assignment to requester, first + pool member, or a system identity. Delegation lands here too: + `on_behalf_of` + `mandate` accepted on the acting verbs, both + identities carried into the audit entry, and a delegated action never + recorded as the original performer acting. + +## 4. TaskService + +- [ ] 4.1 `lib/Service/Task/TaskService.php` skeleton + `create`, `offer`, + `assign`, `reassign`. One write path maintains the + `candidate_users`/`candidate_groups` JSON and the + `openregister_task_candidates` index rows inside one transaction. +- [ ] 4.2 `claim` / `unclaim` — `claim` is a conditional update (assign IF + unassigned) so concurrent claims yield exactly one assignee and a + conflict for the loser, never a silent overwrite. +- [ ] 4.3 `resolve` / `complete` / `cancel` — a non-empty `comment` is + MANDATORY on a rejecting or returning outcome; any verb against an + already-terminal task is refused with a conflict naming the current + state. +- [ ] 4.4 Template freeze and checklist: `template_id` + + `template_version` + `template_snapshot` written at creation, all + later evaluation reading the snapshot; checklist as a typed + `{id,label,description,checked}` array with per-item addressing by + id. +- [ ] 4.5 Audit append written in the SAME transaction as the mutation it + records, for successes AND denials (`authorized: false`), carrying + actor, `performer_type`, `on_behalf_of`, `mandate` and reason. + +## 5. Cancellation propagation + +- [ ] 5.1 Listener terminating every non-terminal task carrying a + `run_uuid` when that run reaches a terminal status (`completed`, + `stopped`, `dead_letter`, `failed` — `lib/Db/FlowRun.php` STATUS + constants), plus an explicit service call for a task made moot by a + branch decision. Idempotent (the reaper in + `lib/Cron/FlowRunWorker.php` can observe terminality more than + once), audited with the propagation source as actor, and a NO-OP for + any task with `run_uuid` null. + +## 6. Inbox + +- [ ] 6.1 `lib/Service/Task/TaskInboxService.php` — assigned-to-me, + unclaimed-in-my-pools, watched-by-me, and by-object queries joined to + the subject object for register/schema/uuid/title. Filtering, + sorting, pagination AND the total run in the datastore; visibility is + part of the WHERE clause, never a post-filter over a wider result. +- [ ] 6.2 Derived-only temporal projection: `overdue`, `days_until_due`, + `days_overdue` computed from `due_at`/`expires_at` against the clock + by ONE function that backs the API projection and the inbox filter + alike. Nothing writes them anywhere. + +## 7. API + +- [ ] 7.1 `lib/Controller/TaskController.php` + `appinfo/routes.php` + entries for the lifecycle verbs and the inbox queries. Every method + declares its auth posture attribute, and every method's actual + authorization is `TaskAuthorizationService` — the attribute is never + the whole check (the gap measured at + `lib/Controller/FlowRunController.php:423-436`). + +## 8. Seed data + +- [ ] 8.1 Install the five seed groups from design.md — Seed Data + (municipal pooled permit check with no run; consultancy delegated + approval with enforcing expiry on a run; travel-agency agent task + with a typed checklist; two terminal tasks including one terminated + by propagation; one audit fixture per task including a DENIED entry) + through the existing seeding path, idempotent on uuid. + +## 9. Tests + +- [ ] 9.1 Table-driven unit tests for the legacy status mapping and the + priority normalisation, each including the live fleet defects as + cases: `'open'` + (`procest/lib/Service/Transitions/CreateTaskHandler.php:76`) and + `"normaal"` (pipelinq `task.priority`) MUST both be refused. +- [ ] 9.2 Authorization and concurrency tests: a stranger denied on every + verb; the two-claim race producing one assignee and one conflict; an + unresolvable role denying rather than passing; a rejection without a + comment refused; an injected audit-write failure leaving the task + NOT completed. +- [ ] 9.3 Inbox and derivation tests: clock-controlled overdue with a + byte-identical row before and after; a pooled task invisible to a + non-member including in the total; datastore pagination returning 25 + of 120 with a correct total; `run_uuid`-null task surviving + unrelated run terminations. +- [ ] 9.4 Playwright coverage for the two `@e2e`-marked scenarios in + `specs/flow-tasks/spec.md`: a stranger refused on the task detail + route, and the inbox route returning tasks with subject context. + +## Acceptance criteria + +- A task with `run_uuid` null passes every verb, every authorization rule + and every inbox query identically to one with a run. No code path treats + it as degraded. +- No column, enum value or persisted field anywhere records overdue. +- Every verb denies before mutating when the authorization answer cannot be + determined; no verb is reachable by knowing a uuid alone. +- No app-named column exists on `openregister_tasks`; a new consuming + entity kind is absorbed by `openregister_task_relations` without a + migration. +- Terminating a run empties its assignees' inboxes of its tasks, with the + reason recorded, and leaves standalone tasks untouched. +- `lib/Service/TaskService.php` (the CalDAV VTODO leaf, 753L) is unchanged + by this work. +- Nothing in this change sends a notification, writes a VTODO, registers a + flow node, or migrates an existing fleet task shape. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- Every new PHP file carries `@license EUPL-1.2` and + `@copyright 2026 Conduction B.V.`; every public/protected method carries + a `@spec openspec/specs/flow-tasks/spec.md` anchor. +- Regression check against opencatalogi and softwarecatalog: both are + additive-migration-only consumers here, so the check is that their + suites are green and no shared service signature changed. +- Depends on `flow-definition-versioning` — `node_id` is a pointer into a + definition and needs the version pinned before it means anything. +- References ADR-098 (D2 native entity, D3 performer types, D4 CMMN + lifecycle, D6 versioning first), ADR-031 (declarative-vs-imperative, + design.md D-1), ADR-001 (seed data), ADR-005 (fail-closed authorization). diff --git a/openspec/changes/flow-task-forms/.openspec.yaml b/openspec/changes/flow-task-forms/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/flow-task-forms/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/flow-task-forms/design.md b/openspec/changes/flow-task-forms/design.md new file mode 100644 index 0000000000..88c2a1eee1 --- /dev/null +++ b/openspec/changes/flow-task-forms/design.md @@ -0,0 +1,489 @@ +# Design: flow-task-forms + +## Context + +See proposal.md — Why for the motivation. What shapes the approach is that +both halves already exist and were built for each other without ever being +introduced. + +**Server — the contract, complete and unused.** +`TransitionEngine::resolveTransitionInputs()` +(`lib/Service/Lifecycle/TransitionEngine.php:697-729`) is the allowlist: +`array_diff(array_keys($data), array_keys($declared))` rejects undeclared +keys (`:701-712`), `collectMissingRequiredInputs()` (`:746-759`) rejects +absent or empty-string required inputs, and `normaliseDeclaredInputs()` +(`:774-790`) reads the `[{field, required}]` shape. It is called from exactly +two places: `transition()` at `:347-352`, where the accepted values are +`array_merge`d into `$objectData` BEFORE the lifecycle field is flipped +(`:356`) so both land in one save; and `:824`, which calls it with +`inputs: []` to assert that a payload-free path really carries no payload. + +**Server — the discovery half, missing.** +`availableActions()` builds each entry at `:477-482` as +`{action, to, requires, description}`; `buildGraphAction()` at `:665-671` +adds `label`. Neither reads `$spec['inputs']`, which is sitting in the same +`$spec` array the loop is already holding (`:457`). The endpoint is +`GET /api/objects/{id}/available-actions` (`appinfo/routes.php:370` → +`TransitionController::availableActions()` at `:148-164`). + +**Server — the error shape, already right.** +`InvalidTransitionInputException` carries `private readonly array $fields` +(`lib/Exception/InvalidTransitionInputException.php:44`) and its own class +docblock (`:29-36`) fixes the status mapping: 400 for a malformed payload, +as opposed to 422 for a refused transition and 403 for unauthorized. +`TransitionController` implements exactly that, returning +`['error' => ..., 'fields' => $e->getFields()]` (`:100-107`). + +**Client — the renderer, and the four places the seams miss.** +`CnFormDialog` props: `schema`, `item`, `register`, `initialData`, +`lockedFields`, `fields`, `excludeFields`, `includeFields`, `fieldOverrides` +(`nextcloud-vue/src/components/CnFormDialog/CnFormDialog.vue:616-705`); it +emits `['close', 'confirm']` (`:739`) and does not persist. `includeFields` +reaches `fieldsFromSchema` as `include` (`:839`). +In `nextcloud-vue/src/utils/schema.js:469-585` the filter order is: +`visible === false` dropped (`:482`), `overrides[key].hidden` dropped +(`:487`), `readOnly === true` dropped unless `overrides[key].readOnly === +false` (`:492`), `exclude` (`:494`), and only THEN `include` (`:496`). +`required` is `requiredKeys.includes(key)` from `schema.required` (`:542`, +`:477`). Sort is `overrides[key].order` → `prop.order` → `localeCompare` +(`:514-519`). +So `fieldOverrides` is the one prop that can repair all of it: it carries +`readOnly: false` to un-drop, `order` to re-sequence, and merged field props +to override the label — and `required` must be merged the same way. + +**Client — the authoring surface is not the one it looks like.** +`CnFormBuilder` has no `schema` prop (`CnFormBuilder.vue:166-219`): `value` +is a free field array, `availableTypes` a type palette, and `key` is typed +by hand (`keyLabel`, `:201`). It builds forms out of nothing, which is the +opposite of what an allowlisted form needs. + +**The external path already has its table.** `FormLink` +(`lib/Db/FormLink.php:63-141`) carries `objectUuid`, `registerId`, +`schemaId` (`:70-84`) — the SAME generic anchor `flow-task-entity` +specifies for a task — plus `formId`, `formHash`, `submissionId`, `status` +and `expiresAt` (`:91-127`). `FormLinkService::createAndLinkForm()` +(`lib/Service/FormLinkService.php:371`) throws 503 when the Forms app is +absent (`:385-398`), and the service's own docblock (`:11-15`) says the +cached metadata exists so surfaces still render when Forms is uninstalled or +the form was deleted. + +**The fleet shapes this has to absorb.** procest's +`workflowTemplate.steps[].config.requiredFields` is `string[]` of case-field +names (`procest/lib/Settings/procest_register.json:3126`), validated by +`StepConfigValidator::validateRequiredFields()` +(`procest/lib/Service/StepConfigValidator.php:266-306`) with three error +codes — `malformed_required_fields`, `malformed_required_field`, +`unknown_field_reference`. The dangling-reference check is +`array_key_exists($field, $caseTypeProperties)` (`:299`), so despite the +schema description calling them "property paths" a dotted path fails today. +procest's `task.checklist` is `"type": "string"` holding JSON +(`procest_register.json:1510-1515`); `flow-task-entity` already replaces it +with a typed array. + +## Goals / Non-Goals + +**Goals:** + +- Make the `inputs` contract usable by giving it its first consumer and its + missing discovery step, without changing what it does to anything that + exists today. +- Specify the binding between the contract and the renderer precisely enough + that the four measured mismatches cannot be reintroduced by the next + caller. +- Move every failure that an author can fix to the moment the author is + present — configuration save time — so the performer only ever sees + failures about their own input. +- Keep the form derivable: declaration × live schema, computed per render, + cached nowhere. + +**Non-Goals:** + +- A form-definition model. No table, no version lineage, no field-type + vocabulary, no designer. See proposal.md — What does NOT change. +- Nested field paths. The contract's `field` is a top-level property name + today, in OpenRegister and in procest's validator alike; making it a path + is a change to the contract, not to its first consumer. +- Mapping an external Forms submission back onto object properties. Binding a + form is not a promise to read it. +- Schema versioning. This design states what a pinned declaration does when + the schema moves; it does not stop the schema moving. +- Rendering. Every widget, layout and per-field validation stays in the + shared component library. This change contributes zero components with a + field widget in them. + +## Decisions + +### D-1 — Declarative-vs-imperative decision (ADR-031) + +**This change lands on the DECLARATIVE side, which is unusual for this +chain, and the imperative parts are three narrow ones that each have a +structural reason.** + +ADR-031's test is: when an `x-openregister-*` schema extension expresses the +requirement, declare it rather than write a service. Applied here, the answer +is unusually clean, because the extension already exists and already does the +work. + +**Declarative — the form itself.** A task form IS +`x-openregister-lifecycle.transitions..inputs` on a schema in the +register. The validator is `resolveTransitionInputs()` +(`TransitionEngine.php:697-729`) and this change adds none of its own. The +crucial property is the one the docblock states at `:680-690`: accepted +values are merged into the carrying object write, so schema validation and +readOnly enforcement apply on the ordinary save path. A bespoke form +validator would have had to re-derive "is this value legal", would have +drifted from the schema, and would have been the second place a readOnly +field could be written. There is nothing to justify here: this is ADR-031's +default path working as designed, for the first time. + +**Imperative — three parts, each fenced.** + +1. **The node's `form` block is flow-graph config.** `lib/Db/Flow.php:5-11` + states the position: a flow definition is deliberately NOT an + OpenRegister object, because definitions used to live in a register and + that meant every app owning flows needed its own register, resolver and + executor. There is no schema to hang an annotation on and no object for + `TransitionEngine` to transition. `flow-definition-versioning`'s design + makes the same argument for the same reason; it is not re-made here. +2. **Publishing `inputs` on `available-actions` is a read-shape change** in + `availableActions()` — a controller response, not a rule. +3. **The binding is client code.** Projecting `required` and `order` onto + `fieldOverrides` happens in the component that mounts `CnFormDialog`. + +**The fence.** The form layer decides WHICH declared fields to render and +WHERE to send the payload. It decides nothing else: + +- whether a value is legal → the schema, on the save path; +- which fields a step wants → the declaration, in the register or in the + pinned node config; +- who may answer → `TaskAuthorizationService` (`flow-task-entity`); +- what happens after the answer → an edge condition on the graph, where the + author can see it. + +A branch in the form layer about what a specific app's field MEANS is out of +bounds. The measurable version of that fence: the form layer contains no +call that writes an object property directly. Every write goes through +`TransitionEngine::transition()` or `ObjectService::saveObject()`. + +**Derived, never stored.** The rendered field list is computed per render +from (pinned node config) × (live schema). It is not cached on the task row. +The fleet has paid for a cached derived value before — three task schemas +store `overdue` and decidesk's `actionOverdue` notification fired only when +something remembered to write it (fixed in decidiq#846). A cached field list +would be worse: it would go stale against a schema change silently, and the +stale reading is "the form is fine". + +**No seed data (ADR-001).** No register and no schema is introduced or +modified by this change. Notably it does NOT add an `inputs` declaration to +any shipped schema: adoption stays zero until an app opts in, in its own +change, which is what makes this change safe to ship on a live instance. + +### D-2 — The field list is a declaration, not a form; two kinds, no third + +Camunda 8 splits a user task's form into `formId` (a form the engine knows) +and `externalReference` (a form it does not). The same split is the right +one here, for the same reason: one path can be validated end-to-end and the +other cannot, and pretending otherwise is where a form engine gets built by +accident. + +- **Native (`kind: fields`)** — the field list. Either `action: ""`, + which means "the inputs that transition declares, verbatim", or an inline + `[{field, required}]` list in the identical shape. +- **External (`kind: external`)** — a `FormLink` to a Nextcloud Forms form. + +Two shapes for the native path rather than one, deliberately. Naming the +action is the better spelling and should be the documented default: the +declaration lives in the register next to the transition it belongs to, one +place, and a schema change and a form change are the same edit. The inline +list exists because a user-task step is not always a transition — a step can +collect fields without moving the subject's lifecycle at all, and forcing a +synthetic transition to exist just to hold a field list would put fake states +in a schema's lifecycle graph. + +Rejected: a third kind that lets a step declare ad-hoc fields not present on +any schema. It is the obvious request, and it is the form engine. An ad-hoc +field has no property definition, so nothing validates its value, nothing +enforces readOnly on it, and it has nowhere to be written — which means +inventing a per-task blob, which means a second place object-ish data lives. +Where a step genuinely needs a field the subject does not have, the answer is +to add the property to the schema, which takes one edit and makes the value +queryable. + +### D-3 — Every author-fixable failure moves to configuration save time + +The renderer drops a field for three reasons before it ever consults the +whitelist (`schema.js:482`, `:487`, `:492`). Two of those — `visible: false` +and `readOnly: true` — are properties of the SCHEMA, knowable the moment a +step is saved. So they are checked then, and the step is refused. + +The alternative is to repair them at render time. `fieldOverrides` can do it: +`overrides[key].readOnly === false` un-drops a readOnly property (`:492`), +which exists precisely for "read-only on edit, collected on create". It is +rejected here, and the reason is that the repair would be silently wrong. A +schema marks a property readOnly to say the save path must refuse a write to +it — and the save path WILL refuse it (`TransitionEngine.php:680-690`: the +merged values go through ordinary readOnly enforcement). Un-dropping it in +the form would render an editable field whose value is guaranteed to be +thrown away or to fail. That is a worse outcome than not rendering it: the +performer fills it in and is told no, or worse, is told yes and the value is +gone. + +`visible: false` has no override at all (`:482`), so a declared invisible +field is unrenderable by construction, not by policy. + +The same argument sends the free-typed field name to save time. A step whose +declaration names `reasonn` produces a payload the allowlist rejects at +`TransitionEngine.php:704-712` — correct behaviour, wrong audience. The +performer cannot spell it right, cannot edit the step and, on the external +path, may not even be an employee. + +What is NOT movable to save time is later schema drift: a property removed +after the step was saved. That surfaces at render, as a disabled row saying +so, and the step is flagged wherever steps are listed. Rejected alternative: +drop the field silently and complete without it. That converts "this +approval required a written reason" into "this approval required nothing", +and reports success — the exact silent-feature-loss shape this fleet keeps +paying for. + +### D-4 — `fieldOverrides` carries `required` and `order`; nothing else is repaired + +Two of the four mismatches ARE render-time repairs, and both are done through +the one prop built for it. + +**`required`.** `schema.js:542` reads `schema.required`, which is the +schema's opinion about the object, not the transition's opinion about this +step. They are different questions and both are legitimate: a `reason` +property can be optional on the object and mandatory when rejecting. So the +binding merges `required: ` into +`fieldOverrides[field]`. Rejected alternative: make the schema require it. +That would make every write of that object require it, including creates that +have nothing to do with the transition — the tail wagging the dog. + +**`order`.** `schema.js:514-519` sorts by `overrides[key].order` first, so +projecting the declaration's array index as `order` makes declared order the +rendered order, without touching the schema's own `order` values that every +other surface (data widget, detail grid) depends on. Rejected alternative: +pass `fields` instead of `includeFields`, hand-building the descriptors in +declaration order. It works and it is a fork: the hand-built list would not +carry `resolveWidget`, reference resolution, semantic-type discovery +(`CnFormDialog.vue:970-1000`), enum handling or the conditional-visibility +pipeline, and it would rot the first time `fieldsFromSchema` gained a +feature. + +Nothing else is repaired. `hidden` overrides, `lockedFields` and +`initialData` stay available to the surface but are not part of this +contract. + +### D-5 — Completion is one of the two existing write paths, never a third + +Where the form names an action, completion calls +`TransitionEngine::transition($objectId, $action, $data)`. That single call +gives four properties for free, all measurable in `:327-362`: the +from-state check (`:334-342`), the allowlist (`:347-352`), the merge before +the flip so "the status write always wins and both land in the same save" +(`:344-346`, `:356`), and the identity snapshot forwarded to the save path so +authorization uses the identity that authorized the transition (`:358-362`). + +Where the form names no action, the accepted fields go through +`ObjectService::saveObject()` after the same allowlist call. This is the only +new call site of `resolveTransitionInputs()`, which means the method moves +from `private` to a narrowly-typed internal service seam. That is the whole +server-side surface of this change. + +**Ordering, and why it is this way round.** The object write commits FIRST, +the task completion second. If the write fails, the task must not be +completed — a completed task whose evidence was refused is a lie in an +inbox, and the run would advance on it. If the task completion fails after +the write succeeded, the write stands and the task stays actionable; the +performer resubmits, and the second submit writes the same values to the +same object, which is idempotent for a value write. The reverse ordering has +no such recovery: it would advance the run on evidence that was never stored. + +**Two authorizations, both apply.** `flow-task-entity` authorizes the verb; +the object write authorizes the write. Being the assignee does not grant +write on the subject. Either may refuse, and a refusal of the second is a +403 that leaves the task open — visible, and correct. + +### D-6 — The pinned flow version is the form's version; there is no second snapshot + +`flow-definition-versioning` pins a version onto the run at queue time and +resolves the graph by `(flow, version)` with a memo keyed on both. The node's +`form` block is part of `nodes`, which is part of the version snapshot row. +So the form of an open task is already frozen — provided resolution goes +through the pinned version. + +Rejected alternative: copy the resolved field list onto the task at creation, +mirroring `flow-task-entity`'s `template_snapshot`. It is redundant against +the version pin and it is not free: the copy would be a derived value stored +on a row (D-1's fence), and it would have to be kept honest against the +schema anyway, because the schema is what the write is validated against. One +source, resolved per render. + +A run-less task is the exception and it needs its own carrier, because there +is no run and therefore no pin. `flow-task-entity` is explicit that a +standalone task is first-class and that no code path may treat "no run" as +degraded — so the task record carries the declaration for that case. The +resolver takes both paths and has no third. + +Failure is loud. An unresolvable pinned version fails the form naming flow +and version, matching `flow-definition-versioning`'s Decision 3, and there is +no fallback to head, to latest-published, or to empty. Empty is the dangerous +one and worth naming: an empty form is completable, so the fallback that +looks most harmless is the one that silently completes a task that required +evidence. + +### D-7 — The checklist is a second surface, not more fields + +A checklist item and a declared field are answered on the same screen and +must not share a payload. A field's value is subject-object state, written +through the allowlist to a property that validates it. A checklist item's +`checked` is TASK state, written through the task's own verbs and audited +there (`flow-task-entity`: "the change MUST appear in the task audit"). + +Merging them would put checklist state through an allowlist with no property +to validate it against — which means either the allowlist rejects it (it +would; the key is not a declared field) or the allowlist is loosened for it, +which is the first hole in the thing this whole change is built on. + +Presenting them together is a layout decision and belongs to the completion +surface; `CnTabbedFormDialog` (`tabs`, `item` — `CnTabbedFormDialog.vue:152-212`) +is the natural shape when both are present and long. Nothing here mandates it. + +"All items must be checked to complete" is a step-level option, refusing the +same way a missing required field refuses: named, not completed, run not +advanced. It is not a field, so it is not in the allowlist; it is a +precondition on the verb. + +### D-8 — The external path binds and records; it does not read + +A `FormLink` is anchored by `objectUuid` + `registerId` + `schemaId` +(`FormLink.php:70-84`), which is exactly the generic anchor +`flow-task-entity` gives a task. So a task's external form resolves through +its subject with no new table and no new column — the strongest argument for +this path over inventing a task-to-form binding. + +What the design refuses to do is map submission answers onto object +properties. NC Forms questions are not schema properties: they have their own +ids, their own types, and no relationship to the register. A mapping layer +would be a second field-declaration dialect with none of the allowlist's +guarantees, sitting next to the first. So: the submission id +(`FormLink.php:106`) is recorded as the evidence, and the subject object is +untouched by this capability. An app that wants the answers written back +declares that separately. + +The install check moves to save time for the same reason as D-3. +`createAndLinkForm()` throws 503 when the Forms classes are absent +(`FormLinkService.php:385-398`), and on a citizen-facing form the person who +would see that 503 is a member of the public. + +The service already caches title, status, `form_hash` and `expires_at` at +link time precisely so a surface degrades gracefully +(`FormLinkService.php:11-15`). This design uses that: an expired or deleted +form makes the task say so, rather than offering a dead link as the way to +finish. + +### D-9 — The authoring surface is the node's server-driven config form + +`CnFormBuilder` is the component that looks like the answer and is not +(`CnFormBuilder.vue:166-219`: no `schema` prop, hand-typed `key`, +free-form type palette). Feeding it schema-derived `availableTypes` would +still leave the `key` field free-typed, and a free-typed key is D-3's +performer-facing 400. + +The node already has the right mechanism. `openregister.user-task` +implements `IFlowNodeConfigForm`, and `FlowNodeRegistry::palette()` publishes +`configForm` for any node declaring one (`FlowNodeRegistry.php:243-250`), +served at `GET /api/flow/node-catalog` (`FlowController.php:213`, +`appinfo/routes.php:508`). So the `form` block's fields are described +server-side, where the subject schema's property list is known, and the +builder renders a constrained multi-select with no editor change — the same +property `flow-user-task-node` relies on for the rest of its config. + +`CnFormBuilder` keeps its existing job. It is not deprecated by this and it +is not used by this. + +## Risks / Trade-offs + +- **Publishing `inputs` on `available-actions` widens a response every client + reads.** → Additive: no existing key changes, and a transition with no + declaration publishes an empty list. The read-permission gate at + `TransitionEngine.php:414-433` is unchanged, so the new data is exactly as + protected as the action names already were. +- **`resolveTransitionInputs()` gains a second caller and stops being + private.** A shared validator is a shared blast radius. → The signature is + pure (`inputs`, `data`, `action` → accepted values, or throw), it has no + state, and the second caller passes the same shapes as the first. The + mitigation is a test asserting both call sites reject the same payloads + identically, rather than a promise that they will. +- **The four binding repairs are client-side, and a future caller can skip + them.** A surface that mounts `CnFormDialog` with `includeFields` and no + `fieldOverrides` silently gets optional-instead-of-required and + alphabetical order. → Mitigated by putting the binding in ONE component + that owns task completion rather than documenting a recipe, and by a test + that renders a form whose declaration disagrees with `schema.required` in + both directions. +- **Schema drift under a pinned form has no automatic repair.** → By design + (D-3): it is visible on the form, flagged in step listings, and refuses + rather than silently narrowing. A repair would mean either editing pinned + versions, which `flow-definition-versioning` forbids outright, or versioning + schemas, which is not in this chain. +- **The native path only reaches properties of ONE schema — the subject's.** + A step that needs a value about something else (a related object, a + free-standing note) has no native home. → Accepted for this change: a + related object is reachable as a reference property, and a genuinely + task-local answer is the outcome and comment `flow-task-entity` already + models. If a real case survives both, it is a schema property. +- **Top-level property names only.** procest's `requiredFields` says "property + paths" but validates a flat name (`StepConfigValidator.php:299`), and + OpenRegister's `normaliseDeclaredInputs()` reads a flat `field` + (`TransitionEngine.php:774-790`). A migrating step that relied on the + description rather than the behaviour will find its dotted path refused. → + Refused at save time with the path named, which is strictly better than + today, where such an entry fails procest's own validator too. +- **Two writes, two failure modes (D-5).** The object write can succeed while + the task completion fails. → Recoverable by construction: the task stays + actionable and the resubmit is idempotent for a value write. The reverse + ordering is not recoverable, which is why it is not used. + +## Migration Plan + +Nothing to migrate, and deliberately nothing to backfill. + +1. Deploy order is the dependency chain: `flow-definition-versioning` → + `flow-task-entity` → `flow-user-task-node` → this change. +2. No schema, table or column is added. No shipped schema gains an `inputs` + declaration, so every transition in the fleet keeps rejecting every payload + exactly as it does today. The 162 files declaring + `x-openregister-lifecycle` are untouched. +3. The `available-actions` response gains a key. Clients that ignore unknown + keys — including `CnLifecycleActions.vue`, which reads + `action`/`to`/`requires`/`description` — are unaffected. +4. Rollback is removing the code. A user-task step that had declared a `form` + block then falls back to outcome-and-comment completion: the block is inert + config in the version snapshot, not a schema change, and it comes back + intact on redeploy. +5. Verification after deploy: `available-actions` carries an `inputs` key for + every action on a schema with no declarations, and it is empty, not + missing; a step declaring a readOnly or absent field is refused at save; a + completion carrying an undeclared key is refused with that key named; and a + published flow edit does not change an already-open task's form. + +## Open Questions + +- **Should the inline field list be allowed at all, or should every native + form name a transition?** Provisionally: allowed, because a step that + collects without moving state is real and the alternative is fake lifecycle + states (D-2). Removing it later is a config-validation change and + invalidates nothing specified here. +- **Where does a declaration live for a step whose subject is chosen at run + time rather than at authoring time?** Provisionally: out of scope — a + user-task step names its subject schema in config, and a step that cannot + is a step whose form cannot be validated at save time, which contradicts + D-3. To be revisited only if `flow-parallel-streams` produces a shape that + needs it. +- **Should a refused completion attempt be written to the task audit?** The + spec permits it and requires only that it be distinguishable from a + completion. Provisionally: yes for a validation refusal on a task with a + form, because "the performer tried three times" is the signal that a form is + wrong. Deferrable — it adds an audit entry type and changes no other + behaviour. diff --git a/openspec/changes/flow-task-forms/proposal.md b/openspec/changes/flow-task-forms/proposal.md new file mode 100644 index 0000000000..608b1c84fc --- /dev/null +++ b/openspec/changes/flow-task-forms/proposal.md @@ -0,0 +1,263 @@ +--- +kind: code +depends_on: [flow-user-task-node] +--- + +# Proposal: flow-task-forms + +## Summary + +Give a human task a structured form without building a form engine. A +user-task node declares WHICH fields of the subject object the performer +supplies; completing the task validates that payload through the lifecycle +transition `inputs` contract that already exists in +`lib/Service/Lifecycle/TransitionEngine.php`, and renders it through the +nc-vue form family that already exists. No new renderer, no new validator, +no form-definition entity. + +The whole change is a binding: **a task form is `CnFormDialog` scoped to the +node's declared fields, submitting to a verb that runs the `inputs` +allowlist.** What this proposal buys is that the binding is specified rather +than reinvented per app — and that the four places where the two existing +seams do NOT meet are closed. + +## Why + +**There is a form contract in the codebase and nobody uses it.** +`resolveTransitionInputs()` (`lib/Service/Lifecycle/TransitionEngine.php:697-729`) +lets a schema declare, per transition, +`inputs: [{"field": "", "required": true|false}]`. Its own +docblock (`:680-690`) states the two properties that make it the right +contract for a task form: + +- a transition with NO `inputs` **rejects ANY payload**, so opting in is + explicit and nothing that exists today changes behaviour; +- the accepted values are **merged into the carrying object write** + (`:347-352`), so ordinary save-path schema validation and readOnly + enforcement apply to them exactly like any other object write. There is + no second validator to keep in sync. + +Adoption is **zero**. Scanning the 22 fleet checkouts under `apps-extra/` +that ship a `lib/Settings` for `"inputs"` in `*.json` returns five hits +total — four in shillinq (`bookkeeping-bado-controleprotocol.json:960`, +`bookkeeping-ifrs-rj-dual-gaap.json:609,1072,1105`) and one in procest +(`register.d/95-dmn-decision-tables.json:35`) — and every one of them is a +different `inputs`: an `x-openregister-calculations` input map, a DMN +decision table, or a seed object's own property. Across the 162 files that +declare `x-openregister-lifecycle`, the transition `inputs` key is declared +**zero times**. `openspec/specs/object-lifecycle/spec.md` does not contain the +word either: the contract is implemented, is docblocked +`@spec openspec/specs/object-lifecycle/spec.md`, and has never been +written down as a requirement. + +**And it is not discoverable, which is why adoption is zero.** +`TransitionEngine::availableActions()` returns +`{action, to, requires, description}` per action (`:477-482`); +`buildGraphAction()` returns the same plus `label` (`:665-671`). Neither +publishes `inputs`. `GET /api/objects/{id}/available-actions` +(`appinfo/routes.php:370`) therefore tells a client every transition it may +take and nothing about what any of them wants. The one shipped consumer +proves the consequence: `CnLifecycleActions.vue:251` POSTs +`{ action: tr.action }` and no `data` at all. A client that guessed would +be refused — a transition with no declared `inputs` rejects any payload — +so the only safe client behaviour is the one that ships, and the contract +stays unused. + +**Meanwhile the renderer exists and is field-scopable.** `CnFormDialog` +takes `schema`, `item`, and an `includeFields` whitelist +(`nextcloud-vue/src/components/CnFormDialog/CnFormDialog.vue:687`, passed +as `include` to `fieldsFromSchema` at `:839`), and emits `confirm` with the +collected payload without persisting it (`:739`) — so the host chooses +where the payload goes. `CnFormPage` renders the same field descriptors +full-page, `CnTabbedFormDialog` groups them. That is a task form already, +missing only its wiring. + +**The wiring is where the money is, because the two seams do not actually +meet.** Four gaps, each measured in `nextcloud-vue/src/utils/schema.js`: + +1. **The whitelist is an intersection, not an override.** `include` is + applied at `:496`, AFTER `visible: false` is dropped at `:482` and + after `readOnly: true` is dropped at `:492`. Naming a field in + `includeFields` does not make it render. A required input that is + readOnly or invisible on its schema renders **nothing**, and the + performer is stuck on a field they cannot see, cannot fill and cannot + skip. +2. **`required` comes from the wrong place.** `:542` sets + `required: requiredKeys.includes(key)` — from `schema.required`, not + from `inputs[].required`. A field the transition requires but the schema + does not renders as optional; the performer submits, and + `collectMissingRequiredInputs()` (`TransitionEngine.php:746-759`) + returns a 400 for a field the form told them was optional. +3. **Declaration order is discarded.** Fields sort by + `overrides[key].order`, then `prop.order`, then alphabetically + (`:514-519`). The order the author wrote the `inputs` in is not the + order the performer sees. +4. **The obvious authoring surface authors the wrong thing.** + `CnFormBuilder` has no `schema` prop (`CnFormBuilder.vue:166-219`): it + builds free-form fields from a type palette, with a free-typed `key`. A + key that is not a property of the subject schema produces a payload the + allowlist rejects — at completion time, in front of the performer, who + cannot fix it. + +**And a form has to survive the flow being edited.** A task created against +version N of a flow must present version N's form. `flow-definition-versioning` +already pins a version onto the run at queue time and resolves the graph +by `(flow, version)`; the form declaration lives in the node config, so it +is pinned for free — but only if the form is resolved through the pinned +version and never off the live `openregister_flows` row. + +## What Changes + +- **A `form` block on the `openregister.user-task` node**, in exactly two + kinds, mirroring Camunda 8's `formId` vs `externalReference` split: + - `kind: fields` — the native path. Either names a lifecycle `action` on + the subject schema and inherits that transition's declared `inputs` + verbatim, or carries its own `[{field, required}]` list in the SAME + shape. One shape, one validator, either way. + - `kind: external` — bring-your-own. Names a Nextcloud **Forms** form + bound through `FormLink` (`lib/Db/FormLink.php:63-141`, + `lib/Service/FormLinkService.php`) for citizen-facing or complex forms + OpenRegister does not model. +- **`available-actions` publishes `inputs`.** Each action in + `TransitionEngine::availableActions()` gains the transition's declared + `inputs` list (empty when none is declared, which is also the honest + statement "this transition accepts no payload"). This is the discovery + step the contract has been missing, and it serves every client, not only + task forms. +- **The binding is specified, not left to each caller.** Rendering a task + form means `CnFormDialog` with `:schema` = the subject object's schema, + `:item` = the subject object, `:includeFields` = the declared field list, + and `:fieldOverrides` carrying, per declared field, `required` from the + contract and `order` from the declaration position — closing gaps 2 and 3 + above. `@confirm` posts the collected values as the completion payload. +- **A declared field that cannot be rendered is refused at SAVE time.** + A field that is not a property of the subject schema, or is `readOnly`, + or is `visible: false`, is rejected when the node's configuration is + validated — with the schema, the field and the reason named. Gap 1 is a + configuration error, and it must land on the author, not on the performer. +- **Completion runs the existing allowlist and nothing else.** Where the + node names a transition, completion goes through + `TransitionEngine::transition()` so the fields and the lifecycle flip + land in ONE save (`:344-356`). Where it does not, the accepted fields are + merged into an ordinary object write. Both paths reject an undeclared key + and a missing required input the same way, because both call the same + method. +- **Validation failure is shown, and the task does not complete.** + `InvalidTransitionInputException` carries `getFields()` + (`lib/Exception/InvalidTransitionInputException.php:44`) and + `TransitionController` already returns `{error, fields}` as a 400 + (`lib/Controller/TransitionController.php:100-107`). The completing + dialog stays open with each named field flagged; the task stays in its + pre-call state in the assignee's inbox; the run does not advance; no + completion is written to the task audit. +- **The form is resolved from the run's PINNED version.** A task carrying a + `run_uuid` resolves its form through the flow version that run is pinned + to. A standalone task (no run — first-class per `flow-task-entity`) + carries its form declaration on the task record. Neither ever reads the + editable head. +- **The checklist becomes part of the completion surface.** + `flow-task-entity` already turns procest's JSON-in-a-string checklist + (`procest/lib/Settings/procest_register.json:1510-1515`, `"type": "string"`) + into a typed `{id, label, description, checked}` array. The task form + renders it as an addressable section BESIDE the field form, never merged + into it: a checklist item is task state, a declared field is subject-object + state, and one of them goes through the `inputs` allowlist. +- **procest's `config.requiredFields[]` gets a target.** + `workflowTemplate.steps[].config.requiredFields` + (`procest/lib/Settings/procest_register.json:3126`, validated at + `procest/lib/Service/StepConfigValidator.php:266-306`) maps 1:1 onto + `inputs: [{field, required: true}]`. Measured caveat carried forward: its + own description says "case-field property paths", but the validator + accepts only a TOP-LEVEL property name + (`array_key_exists($field, $caseTypeProperties)`, `:299`) — a dotted path + fails today, so the target contract inherits flat property names and a + nested path is a later decision, not an assumed capability. + +## What does NOT change + +- **No new form renderer, and no form-definition entity.** Every field is + rendered by the nc-vue form family that ships today. There is no + `openregister_forms` table, no form versioning of its own, no form + designer. A form is a field list plus a schema. +- **`flow-task-entity`** — the task record, the ten lifecycle verbs, the + fail-closed authorization, the typed checklist array, the append-only + audit, the inbox. This change adds no task field and no verb; it says + what a completion payload must satisfy before a verb accepts it. +- **`flow-user-task-node`** — the node itself, `FlowSuspension` and resume + on task terminality, per-item outcome placement, rejection-as-branch, the + `advance` budget, cancellation propagation. This change adds ONE config + block to a node that change ships. +- **`flow-task-inbox-projections`** — `INotificationManager` notifications + and the CalDAV VTODO projection with its authorizing write-back listener. + A form renders nothing into a calendar and notifies nobody. +- **`flow-business-timers`** — SLA arithmetic, business days, escalation + matrices, `expires_at` enforcement. A form has no clock. +- **Schema versioning.** `x-openregister-lifecycle` lives on a schema, and + schemas are not versioned by `flow-definition-versioning` or by anything + else in this chain. This change does not introduce it; it states what a + task does when the schema has drifted under a pinned form, which is a + visible refusal rather than a silent omission. +- **Existing lifecycle behaviour.** No shipped schema gains an `inputs` + declaration here. Every transition in the fleet keeps rejecting any + payload, exactly as it does today, until an app opts in. + +## Capabilities + +### New Capabilities +- `flow-task-forms`: the task form contract — how a user-task node declares + the fields a performer supplies, how that declaration is validated at save + time, how it is rendered and bound in the existing nc-vue form family, how + a completion payload is validated through the lifecycle `inputs` + allowlist, what a validation failure shows and leaves unchanged, how the + form is resolved from a pinned flow version, and the external + Nextcloud-Forms path for citizen-facing forms. + +### Modified Capabilities +- `object-lifecycle`: the transition `inputs` contract gains its first + written requirement, and `available-actions` gains the discovery half — + a client MUST be able to learn which fields a transition accepts and + which are required, without reading the schema. + +## Impact + +- **Affected specs**: new `flow-task-forms`; `object-lifecycle` gains + requirements for the `inputs` contract and its discovery. `flow-engine` + untouched — no node semantics, no run semantics change here. +- **Affected code**: `lib/Service/Lifecycle/TransitionEngine.php` + (`availableActions()` at `:403-486` and `buildGraphAction()` at + `:640-671` publish `inputs`; `resolveTransitionInputs()` at `:697-729` is + reused unchanged and becomes callable for a task-completion payload); + a new `lib/Service/Task/TaskFormResolver.php` (pinned-version → node → + field list, intersected with the live schema); the `openregister.user-task` + node's `configForm()` and `validateConfig()` gain the `form` block; + `lib/Service/FormLinkService.php` is consumed unchanged for the external + path. No migration of its own. +- **Affected APIs**: `GET /api/objects/{id}/available-actions` + (`appinfo/routes.php:370`) gains an `inputs` array per action — additive, + no existing key changes. `POST /api/objects/{id}/transition` + (`appinfo/routes.php:369`) is unchanged; it already accepts `data` + (`TransitionController.php:86-93`). Task completion happens on + `flow-task-entity`'s task verbs. +- **Affected UI (nc-vue)**: the task-completion surface binds `CnFormDialog` + with `includeFields` + `fieldOverrides` as specified above; + `CnLifecycleActions.vue` gains the ability to send `data` for a transition + that declares `inputs`, which today it cannot (`:251`). `CnFormBuilder` is + NOT used to pick subject-object fields — it authors free-typed keys + (`CnFormBuilder.vue:166-219`) and a free-typed key is a 400 the performer + cannot fix; the node's server-driven config form + (`IFlowNodeConfigForm` → `GET /api/flow/node-catalog`) offers the subject + schema's property names instead. +- **Affected apps**: none required. procest's `requiredFields` and + `checklist`, and any citizen-facing FormLink flow, migrate in their own + changes. +- **Depends on**: `flow-user-task-node` (the node whose config gains the + `form` block), and transitively `flow-task-entity` (the record a + completion payload is written against) and `flow-definition-versioning` + (without a pinned version, "the form version N declared" is not a + resolvable phrase). +- **ADRs**: ADR-098 D5 (task forms reuse existing seams; no new renderer), + D6 (versioning before humans), D2/D3 (the record and the performer types + a form is completed by); ADR-031 (declarative-vs-imperative — argued in + design.md, and this change is unusual in landing on the declarative side); + ADR-065 (one engine); ADR-011 (reuse before implementing — the whole + point). diff --git a/openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md b/openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md new file mode 100644 index 0000000000..dc0ee94412 --- /dev/null +++ b/openspec/changes/flow-task-forms/specs/flow-task-forms/spec.md @@ -0,0 +1,376 @@ +## Purpose + +The contract for the structured form a human task presents: which fields of +the subject object a performer supplies, how that declaration is refused +when it cannot be rendered, how the payload is validated on completion, what +a failure shows and leaves unchanged, and how the form stays the one the +flow version declared after the flow is edited. + +## ADDED Requirements + +### Requirement: A task form is a declaration of existing fields, not a new form definition + +A user-task step SHALL be able to declare a form. A declared form SHALL take +exactly one of two kinds and SHALL NOT introduce a form definition of its +own. + +**Native.** The form names fields of the SUBJECT object's schema, in the +same shape the lifecycle transition input contract uses: +`[{field, required}]`. A form MAY obtain that list by naming a lifecycle +action on the subject schema, in which case the transition's declared inputs +are the field list verbatim and SHALL NOT be restated. A form MAY instead +carry its own list in that same shape. + +**External.** The form names a Nextcloud Forms form bound to the subject +object, for citizen-facing or complex forms the register does not model. + +No third kind SHALL exist. The system SHALL NOT introduce a form-definition +record, a form version lineage, or a field type vocabulary of its own: a +form is a field list plus the schema those fields already belong to. + +A user-task step with NO declared form SHALL keep working exactly as it does +without this capability — the performer supplies an outcome and a comment +and nothing else. Declaring a form SHALL be opt-in per step. + +#### Scenario: A step inherits a transition's declared inputs + +- **GIVEN** a subject schema whose `reject` transition declares two inputs, + one required +- **WHEN** a user-task step declares a native form naming that action +- **THEN** the step's field list MUST be exactly those two fields with that + required flag +- **AND** the step configuration MUST NOT have restated either field +- @e2e exclude covered by task-form resolution unit tests + +#### Scenario: A step with no form still completes + +- **GIVEN** a user-task step declaring no form +- **WHEN** its task is completed with an outcome and a comment +- **THEN** the completion MUST be accepted +- **AND** no field validation MUST have been applied +- @e2e a task with no form completes with an outcome alone + +### Requirement: A field that cannot be rendered is refused when the step is saved + +The system SHALL validate every declared field against the subject schema at +step-configuration save time, and SHALL REFUSE the configuration naming the +schema, the field and the reason when a field: + +- is not a property of the subject schema; or +- is marked read-only on that schema; or +- is marked not visible on that schema. + +This is normative because of where the failure otherwise lands. The whitelist +that scopes the rendered form is applied AFTER the renderer has already +dropped read-only and invisible properties, so such a field renders nothing +at all. A field declared `required` that renders nothing leaves the performer +holding a form they cannot complete, cannot skip and cannot diagnose — and +the person who can fix it is the author, who is not in the room. + +An authoring surface for the field list SHALL offer the subject schema's +property names and SHALL NOT accept a free-typed field name. A field name +that is not a schema property produces a payload the completion allowlist +rejects, which surfaces to the performer as a refusal they cannot act on. + +Where the subject schema changes after a step was saved so that a declared +field becomes absent, read-only or invisible, the step SHALL be reported as +broken wherever steps are listed, and the field SHALL render as a disabled +row explaining why. It SHALL NOT be silently omitted from the form: a +silently-omitted required field turns into a completion that is refused for +a reason nobody can see. + +#### Scenario: A misspelled field is refused at save time + +- **GIVEN** a user-task step whose native form declares a field the subject + schema does not have +- **WHEN** the step configuration is validated +- **THEN** validation MUST fail naming the schema and the field +- **AND** the step MUST NOT be saved +- @e2e exclude covered by user-task config-validation unit tests + +#### Scenario: A read-only field is refused rather than rendered blank + +- **GIVEN** a user-task step declaring a field its subject schema marks + read-only +- **WHEN** the step configuration is validated +- **THEN** validation MUST fail with a reason naming read-only +- @e2e exclude covered by user-task config-validation unit tests + +#### Scenario: A field the schema dropped later is visible as broken + +- **GIVEN** a saved step whose declared field was removed from the subject + schema afterwards +- **WHEN** a performer opens the task's form +- **THEN** the field MUST appear as a disabled row stating that the schema no + longer offers it +- **AND** the form MUST NOT present as complete and correct +- @e2e a task form whose schema drifted shows the missing field as broken + +### Requirement: The rendered form carries the declaration's required flags and order + +The rendered form SHALL present exactly the declared fields, and SHALL carry +two properties from the DECLARATION rather than from the schema: + +- **Required.** A field the declaration marks required SHALL render as + required, whether or not the subject schema lists it as required. The + renderer derives required-ness from the schema's own required list, so a + transition-required field that is schema-optional would otherwise render as + optional and the performer would be refused on submit for a field the form + told them they could leave blank. +- **Order.** Fields SHALL render in the order they were declared. The + renderer sorts by an explicit order, then the property's own order, then + alphabetically, so an unprojected declaration order is discarded. + +The system SHALL NOT reimplement any field widget, layout or validation +already provided by the shared component library. Rendering a task form +SHALL mean scoping the existing schema-driven form component to the declared +field list; the component collects values and hands them back without +persisting them, and this capability decides where they go. + +The rendered field list SHALL be DERIVED on each render from the declaration +and the live schema. It SHALL NOT be cached on the task record. + +#### Scenario: A transition-required field renders as required + +- **GIVEN** a declared field marked required whose subject schema does not + list it among the schema's required properties +- **WHEN** the form renders +- **THEN** that field MUST be presented as required +- @e2e a task form marks a transition-required field as required + +#### Scenario: Declared order survives to the screen + +- **GIVEN** a form declaring three fields in an order that is neither the + schema's own order nor alphabetical +- **WHEN** the form renders +- **THEN** the three fields MUST appear in the declared order +- @e2e exclude covered by task-form binding unit tests + +### Requirement: A completion payload is validated by the lifecycle input allowlist and by nothing else + +Completing a task that has a native form SHALL validate the submitted values +through the SAME lifecycle transition input allowlist an object write uses. +The system SHALL NOT introduce a second validator. + +That allowlist SHALL be applied unchanged, including both of its existing +properties: + +- A key the form did not declare SHALL be REJECTED. A step declaring no + fields SHALL accept no field values at all. +- Accepted values SHALL be merged into the carrying object write, so ordinary + save-path schema validation and read-only enforcement apply to them exactly + as to any other object write. No value SHALL reach storage having been + checked only by the form layer. + +Where the form names a lifecycle action, the accepted values and the +lifecycle field change SHALL be written in ONE save, so a task cannot be +completed with values that were accepted while the state change was refused, +or the reverse. + +Where the form names no action, the accepted values SHALL be written to the +subject object through the ordinary object write path, under the same +allowlist. + +Neither path SHALL bypass the object write's own authorization. A performer +authorized to complete a task is not thereby authorized to write the subject +object; both checks SHALL apply and either SHALL be able to refuse. + +#### Scenario: An undeclared key is rejected + +- **GIVEN** a task whose form declares one field +- **WHEN** a completion is submitted carrying that field and one more +- **THEN** the completion MUST be refused naming the undeclared field +- **AND** the subject object MUST be unchanged +- @e2e exclude covered by completion-payload validation unit tests + +#### Scenario: A value that violates the schema is refused by the schema + +- **GIVEN** a declared field whose submitted value violates its property + definition +- **WHEN** the completion is submitted +- **THEN** the write MUST be refused by the ordinary save-path validation +- **AND** the task MUST NOT be completed +- @e2e exclude covered by completion-payload validation unit tests + +#### Scenario: Values and the state change land together + +- **GIVEN** a form naming a lifecycle action, and a submitted value the + schema will refuse +- **WHEN** the completion is submitted +- **THEN** the subject object's lifecycle field MUST be unchanged +- **AND** no partial write MUST be observable +- @e2e exclude covered by transition-write unit tests + +### Requirement: A validation failure names its fields and completes nothing + +When a completion payload fails validation, the response SHALL carry the +offending field names in a machine-readable form alongside the human +message, distinguishing an undeclared key from a missing required input. + +The completing surface SHALL stay open with the submitted values intact and +SHALL flag each named field inline. It SHALL NOT discard what the performer +typed, and SHALL NOT present the failure as a generic error when the failing +fields are known. + +The task SHALL NOT complete. Specifically, after a failed completion: + +- the task SHALL remain in the state it was in before the call, with the same + assignee; +- the task SHALL still appear as actionable in that assignee's inbox; +- the run, where the task has one, SHALL remain suspended and SHALL NOT + advance by any amount; +- the task audit SHALL NOT record a completion. It MAY record a refused + attempt; a refused attempt and a completion SHALL be distinguishable. + +#### Scenario: A missing required field is named and the task stays open + +- **GIVEN** a task whose form declares a required field +- **WHEN** a completion is submitted without it +- **THEN** the response MUST name that field +- **AND** the task MUST still be actionable in its assignee's inbox +- **AND** its run MUST still be suspended +- @e2e a task form missing a required field refuses and stays in the inbox + +#### Scenario: The performer does not lose their typing + +- **GIVEN** a form filled with several values, one of which fails validation +- **WHEN** the failure is shown +- **THEN** the other values MUST still be present in the form +- @e2e exclude covered by task-form component tests + +#### Scenario: A refused completion is not an audited completion + +- **GIVEN** a task whose completion was refused for a missing required field +- **WHEN** its audit trail is read +- **THEN** it MUST NOT contain a completion entry +- @e2e exclude covered by task audit unit tests + +### Requirement: The form a task presents is the one its flow version declared + +A task carrying a run reference SHALL resolve its form through the flow +DEFINITION VERSION that run is pinned to, and SHALL NOT read the editable +head of the flow. Editing a flow SHALL NOT change the form of any task +already created against an earlier version, and SHALL NOT change it while +the task is open. + +A task carrying no run reference SHALL carry its own form declaration on the +task record. A run-less task is first-class, so "resolve it from the pinned +version" MUST NOT be the only path to a form. + +Where the pinned version cannot be resolved, the task's form SHALL fail +visibly naming the flow and the version. It SHALL NOT fall back to the head, +to the latest published version, or to an empty form. An empty form is the +worst of the three: it silently turns a task that required evidence into one +that required nothing, and reports success. + +The subject SCHEMA is not versioned by this capability. Where a pinned +declaration and the live schema disagree, the live schema SHALL govern +whether a value may be written, and the disagreement SHALL be reported on +the form as specified above — never resolved by silently dropping a field. + +#### Scenario: Editing the flow leaves an open task's form alone + +- **GIVEN** a task created from a user-task step whose form declares two + fields +- **WHEN** the flow is edited to declare four fields on that step and a new + version is published +- **THEN** the open task's form MUST still present two fields +- @e2e editing a flow does not change an already-open task's form + +#### Scenario: A new task gets the new form + +- **GIVEN** the same flow after the edit above +- **WHEN** a new run reaches that step +- **THEN** its task's form MUST present four fields +- @e2e exclude covered by task-form resolution unit tests + +#### Scenario: An unresolvable version fails loudly + +- **GIVEN** a task whose run is pinned to a flow version that cannot be + resolved +- **WHEN** a performer opens its form +- **THEN** the failure MUST name the flow and the version +- **AND** no empty form MUST be presented as completable +- @e2e exclude covered by task-form resolution unit tests + +### Requirement: A checklist is presented beside the field form, never merged into it + +Where a task carries a checklist, the completion surface SHALL present it as +its own addressable section, separate from the declared field form. + +A checklist item's checked state SHALL be written as task state, through the +task's own verbs, and SHALL NOT be submitted as a field value. A declared +field's value SHALL be written to the subject object through the input +allowlist. The two SHALL NOT share a payload: one is answered about the work, +the other is answered about the subject, and merging them would put checklist +state through an allowlist that has no property to validate it against. + +A step MAY require that every checklist item is checked before the task may +be completed. Where it does, an unchecked item SHALL refuse the completion +naming the item, under the same rules as a missing required field — the task +does not complete and the run does not advance. + +#### Scenario: Checking an item does not write the subject object + +- **GIVEN** a task with a checklist and a declared field form +- **WHEN** a performer checks one checklist item +- **THEN** only that item's state MUST change +- **AND** the subject object MUST be unchanged +- @e2e exclude covered by checklist and completion unit tests + +#### Scenario: An unchecked mandatory item refuses the completion + +- **GIVEN** a step requiring every checklist item to be checked, and a task + with one item unchecked +- **WHEN** a completion is submitted +- **THEN** it MUST be refused naming the unchecked item +- **AND** the task MUST remain actionable +- @e2e a task with an unchecked mandatory checklist item refuses completion + +### Requirement: The external form path binds an existing Forms form and validates nothing about its contents + +A step declaring an external form SHALL name a Nextcloud Forms form bound to +the subject object through the existing object-to-form link, which is +anchored by object uuid, register and schema — the same anchor a task already +uses for its subject. No new binding table SHALL be introduced. + +The system SHALL be explicit about what it does NOT do on this path: the +external form's fields are not the subject schema's properties, so the input +allowlist does not apply and no value from the submission is written to the +subject object by this capability. Completion on this path records the +submission as the evidence that the work was done. Any mapping from a +submission back onto object properties is a separate, declared act and is not +implied by binding a form. + +A step declaring an external form SHALL be REFUSED at configuration save time +on an instance where the Forms app is not installed. The link service already +fails with an unavailable-service error when its classes are absent; letting +that surface at completion time would put the failure in front of the +performer — and on a citizen-facing form, in front of a member of the public +who has no way to act on it. + +Where the bound form has been deleted, archived or has expired, the task +SHALL say so and SHALL NOT present an unusable link as the way to complete +the work. + +#### Scenario: An external step is refused without the Forms app + +- **GIVEN** an instance without the Nextcloud Forms app +- **WHEN** a user-task step declaring an external form is saved +- **THEN** the configuration MUST be refused naming the missing app +- @e2e exclude covered by user-task config-validation unit tests + +#### Scenario: An external submission writes no object fields + +- **GIVEN** a task bound to an external form, completed through a submission +- **WHEN** the subject object is read +- **THEN** no property MUST have been written by this capability +- **AND** the completion MUST record the submission +- @e2e exclude covered by external-form completion unit tests + +#### Scenario: An expired bound form is not offered as the way to finish + +- **GIVEN** a task whose bound form has expired +- **WHEN** a performer opens the task +- **THEN** the task MUST state that the form is unavailable +- @e2e exclude covered by form-link state unit tests diff --git a/openspec/changes/flow-task-forms/specs/object-lifecycle/spec.md b/openspec/changes/flow-task-forms/specs/object-lifecycle/spec.md new file mode 100644 index 0000000000..14d5f6373f --- /dev/null +++ b/openspec/changes/flow-task-forms/specs/object-lifecycle/spec.md @@ -0,0 +1,103 @@ +## ADDED Requirements + +### Requirement: A transition MAY declare `inputs[]` bounding the payload it accepts + +A schema's `x-openregister-lifecycle.transitions[]` MAY declare an +`inputs` array whose entries name a property of that schema and whether it is +required: `inputs: [{"field": "", "required": true|false}]`. + +OpenRegister MUST enforce that declaration as an ALLOWLIST when a transition +is applied with a payload: + +- A payload key the transition does not declare MUST be REJECTED. A + transition that declares no `inputs` therefore accepts NO payload at all — + which is the existing behaviour of every transition in the fleet and MUST + remain so, so that opting in is explicit and no schema changes behaviour by + this requirement being written down. +- A declared `required` input that is absent from the payload, or supplied as + an empty string, MUST be REJECTED. +- Accepted values MUST be merged into the object write that carries the + transition, so ordinary save-path schema validation and read-only + enforcement apply to them exactly as to any other object write. OpenRegister + MUST NOT validate an accepted value only against the `inputs` declaration: + the declaration says which fields may be supplied, and the schema says what + a legal value is. +- The accepted values and the lifecycle field change MUST land in ONE save, so + a caller cannot observe an object whose values were written while its state + change was refused, or the reverse. + +A rejection MUST carry the offending field names in a machine-readable form +alongside the human message, and MUST distinguish an undeclared key from a +missing required input. A malformed payload is a client error and MUST be +reported as one, distinctly from a transition that is refused from the current +state and from one the caller is not authorized to take. + +Declaring `inputs` MUST NOT change which transitions are available, who may +take them, or what any existing declared guard, authorization gate or +`actions[]` entry does. + +#### Scenario: A transition with no declared inputs rejects a payload +- **GIVEN** a schema whose `approve` transition declares no `inputs` +- **WHEN** the transition is applied with a payload containing one field +- **THEN** the call MUST be rejected naming that field as undeclared +- **AND** the object's lifecycle field MUST be unchanged + +#### Scenario: A declared required input is enforced +- **GIVEN** a `reject` transition declaring `inputs: [{"field": "reason", "required": true}]` +- **WHEN** the transition is applied with an empty payload +- **THEN** the call MUST be rejected naming `reason` as a missing required input +- **AND** the response MUST carry that field name machine-readably + +#### Scenario: An accepted value is still validated by the schema +- **GIVEN** a `reject` transition declaring `reason` as an input, where the + schema constrains `reason` to an enumerated set +- **WHEN** the transition is applied with a `reason` outside that set +- **THEN** the save-path validation MUST refuse the write +- **AND** the object's lifecycle field MUST be unchanged + +#### Scenario: An accepted value cannot overwrite a read-only property +- **GIVEN** a transition declaring an input naming a property the schema marks + read-only +- **WHEN** the transition is applied supplying that property +- **THEN** the read-only enforcement on the save path MUST apply exactly as it + does to any other object write + +### Requirement: The available-actions response MUST publish each action's declared inputs + +Every action returned by the available-actions endpoint MUST carry the +declared `inputs` for that transition — the field names and their required +flags — so a client can present the payload the transition expects without +reading the schema. + +An action whose transition declares no inputs MUST carry an EMPTY inputs list +rather than omitting the key. Absent and empty MUST NOT be the same value on +this response: empty is the positive statement "this transition accepts no +payload", which is exactly what the allowlist enforces, and a client must be +able to read it as such rather than infer it from silence. + +This MUST hold for actions derived from a static transition map and for +actions derived at runtime from a graph block, so a client's handling of the +response does not have to know which mode a schema uses. + +The endpoint's existing per-action keys MUST be unchanged, and the existing +read-permission check that gates the response MUST be unchanged: publishing +what a transition accepts MUST NOT be reachable by a caller who may not read +the object. + +#### Scenario: A declaring transition publishes its fields +- **GIVEN** a schema whose `reject` transition declares two inputs, one required +- **AND** an object in a state from which `reject` is available +- **WHEN** the available-actions endpoint is called for that object +- **THEN** the `reject` action MUST carry both field names with their required flags + +#### Scenario: A non-declaring transition publishes an empty list +- **GIVEN** a transition declaring no `inputs` +- **WHEN** the available-actions endpoint is called +- **THEN** that action MUST carry an empty inputs list +- **AND** the key MUST be present + +#### Scenario: A caller without read permission still learns nothing +- **GIVEN** a user without read permission on an object +- **WHEN** they call the available-actions endpoint for it +- **THEN** the call MUST be refused +- **AND** no field name from any transition MUST appear in the response diff --git a/openspec/changes/flow-task-forms/tasks.md b/openspec/changes/flow-task-forms/tasks.md new file mode 100644 index 0000000000..631e623e5e --- /dev/null +++ b/openspec/changes/flow-task-forms/tasks.md @@ -0,0 +1,162 @@ +# Tasks: flow-task-forms + +## 1. The contract gets its discovery half + +- [ ] 1.1 `TransitionEngine::availableActions()` (`lib/Service/Lifecycle/TransitionEngine.php:457-484`) + publishes each action's declared `inputs` — normalised through + `normaliseDeclaredInputs()` (`:774-790`) so the response shape is the + contract's shape — and `buildGraphAction()` (`:640-671`) does the same + for graph mode. A transition with no declaration publishes an EMPTY + list, never an absent key. The read-permission gate at `:414-433` is + untouched. +- [ ] 1.2 Make `resolveTransitionInputs()` (`:697-729`) reachable from a + second caller without duplicating it: one narrow internal seam, + unchanged signature and unchanged throws. Both call sites MUST refuse + the same payloads — assert it with a shared test fixture, not by + reading the code. +- [ ] 1.3 Write the `inputs` contract into `openspec/specs/object-lifecycle/` + via this change's delta. The contract has shipped since the transition + engine's input work and has never been a requirement; the delta is the + first time the allowlist and its 400 shape are specified. + +## 2. Declaring a form on a user-task step + +- [ ] 2.1 The `openregister.user-task` node's `configForm()` gains the `form` + block — `kind: fields | external`, an optional lifecycle `action`, an + inline `[{field, required}]` list, and the external form reference — + served unchanged through `FlowNodeRegistry::palette()` + (`FlowNodeRegistry.php:243-250`) and `GET /api/flow/node-catalog`, so + the builder needs no editor change (design.md, D-9). The field picker + offers the subject schema's property names and accepts no free-typed + field name; `CnFormBuilder` is NOT used — it has no `schema` prop and a + hand-typed `key` + (`nextcloud-vue/src/components/CnFormBuilder/CnFormBuilder.vue:166-219`). +- [ ] 2.2 `validateConfig()` refuses, naming schema + field + reason: a field + that is not a property of the subject schema; a field the schema marks + `readOnly`; a field the schema marks `visible: false`; both an `action` + and an inline list; and an `action` the subject schema does not declare. +- [ ] 2.3 `validateConfig()` refuses `kind: external` when the Forms app is + not installed, mirroring `FormLinkService::createAndLinkForm()`'s 503 + (`lib/Service/FormLinkService.php:385-398`) at authoring time rather + than in front of the performer. + +## 3. Resolving the form + +- [ ] 3.1 `lib/Service/Task/TaskFormResolver.php` — a task with a `run_uuid` + resolves its declaration through the run's PINNED flow version + (`flow-definition-versioning`), never the editable head; a run-less task + reads the declaration off its own record. No third path, no fallback to + head/latest/empty, and an unresolvable version fails naming flow AND + version. +- [ ] 3.2 The resolver intersects the declaration with the LIVE subject + schema on every call and returns per field: render / broken-with-reason. + Nothing is cached on the task row (design.md, D-1 — derived, never + stored). +- [ ] 3.3 Expose the resolved form on the task read so the completion surface + needs no second round-trip, carrying each field's `required` from the + DECLARATION and its position from the declaration order. + +## 4. Completing with a payload + +- [ ] 4.1 Completion with a form that names an action calls + `TransitionEngine::transition($objectId, $action, $data)` so the + allowlist, the merge and the lifecycle flip land in one save + (`:344-356`); completion with an inline list runs the same allowlist and + writes through `ObjectService::saveObject()`. +- [ ] 4.2 Ordering and failure: the object write commits BEFORE the task is + completed. A refused write leaves the task actionable and the run + suspended; a task-completion failure after a successful write leaves the + write standing and the resubmit idempotent (design.md, D-5). +- [ ] 4.3 Both authorizations apply and either may refuse: the task verb's + (`flow-task-entity`) and the object write's. Being the assignee grants + no write on the subject. +- [ ] 4.4 Checklist completion stays on the task's own verbs and never enters + the field payload; the optional "every item checked" precondition + refuses a completion naming the unchecked item, without advancing the + run. + +## 5. Rendering and the binding + +- [ ] 5.1 One task-completion component owns the binding: `CnFormDialog` with + `:schema` = the subject schema, `:item` = the subject object, + `:includeFields` = the declared fields, and `:fieldOverrides` carrying + `required` from the declaration and `order` from the declaration index — + the two repairs for `nextcloud-vue/src/utils/schema.js:542` and + `:514-519`. `@confirm` posts the payload; the component does not + persist. +- [ ] 5.2 Failure surfaces, both kinds. A BROKEN field (the schema dropped it, + or made it readOnly/invisible after the step was saved) renders as a + disabled row stating why, and the step is flagged wherever steps are + listed — never silently omitted. A REFUSED completion keeps the dialog + open with the typed values intact and flags each field named in the + 400's `fields` array + (`lib/Exception/InvalidTransitionInputException.php:44`, + `lib/Controller/TransitionController.php:100-107`), distinguishing an + undeclared key from a missing required input. +- [ ] 5.3 `CnLifecycleActions.vue:251` gains the ability to send `data` for a + transition whose published `inputs` are non-empty, and keeps sending + `{action}` alone when they are empty. +- [ ] 5.4 External path: the task presents the bound Forms form through + `FormLink` (`lib/Db/FormLink.php:70-127`), resolved via the subject + anchor with no new table; an expired, deleted or archived form makes the + task say so instead of offering a dead link. + +## 6. Tests + +- [ ] 6.1 Contract tests: an undeclared key, a missing required input, an + empty-string required input, and a payload against a transition with no + declaration — each refused, each naming its fields, each leaving the + lifecycle field unchanged; plus an accepted value that the schema then + refuses, and one naming a readOnly property. Discovery alongside: + `available-actions` publishes `inputs` for static and graph modes, an + empty list is present rather than absent, and a caller without read + permission gets nothing. +- [ ] 6.2 Binding tests: a declaration whose `required` disagrees with + `schema.required` in BOTH directions renders correctly; declared order + survives a schema whose own `order` disagrees; a readOnly or invisible + declared field is refused at save and never reaches a render. +- [ ] 6.3 Versioning and regression: an open task keeps its form across a + publish that changes the step, while a new run gets the new form; an + unresolvable pinned version fails loudly; and a pass with opencatalogi + and softwarecatalog installed proving their lifecycle transitions still + list and still apply unchanged. + +## Acceptance criteria + +- No shipped schema gains an `inputs` declaration in this change. A grep for + `"inputs"` under every app's `lib/Settings` returns the same five unrelated + hits it returned before (four in shillinq, one in procest), and every + transition in the fleet still rejects every payload. +- There is exactly one implementation of the input allowlist. A grep shows one + method that rejects undeclared keys and one that collects missing required + inputs, with no second copy under a task, form or flow namespace. +- Every `available-actions` response carries an `inputs` key on every action, + including actions whose transitions declare none — where it is present and + empty. +- No task row stores a rendered field list, a field descriptor, or a copy of a + declaration resolvable from a pinned version. +- A form declaration that cannot be rendered is refused at configuration save + time. A performer never sees a validation failure caused by a field name the + author got wrong. +- A failed completion leaves the task in its pre-call state, actionable in the + same inbox, with its run suspended and no completion entry in its audit. +- Editing and publishing a flow changes the form of zero already-open tasks. +- This change contributes no new form renderer and no component containing a + field widget. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- New PHP files carry `@license EUPL-1.2` and `@copyright 2026 Conduction B.V.` +- `@spec` annotations point at + `openspec/specs/flow-task-forms/spec.md` and, for the engine changes, + `openspec/specs/object-lifecycle/spec.md` anchors. +- Every user-visible string is wrapped per the app's l10n rules; new keys land + in `l10n/en.js` for frontend strings and in the backend catalogue for PHP + strings, never the other way round. +- References ADR-098 Decision 5 (reuse the existing seams; no new renderer), + Decision 6 (the pinned version supplies the form), ADR-031 (argued in + design.md — this change lands on the declarative side), ADR-011 (reuse + before implementing). +- No form-definition table, version lineage or field-type vocabulary is + introduced, and no partial hook for one is left behind. diff --git a/openspec/changes/flow-task-inbox-projections/.openspec.yaml b/openspec/changes/flow-task-inbox-projections/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/flow-task-inbox-projections/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/flow-task-inbox-projections/design.md b/openspec/changes/flow-task-inbox-projections/design.md new file mode 100644 index 0000000000..21abc1042e --- /dev/null +++ b/openspec/changes/flow-task-inbox-projections/design.md @@ -0,0 +1,622 @@ +# Design: flow-task-inbox-projections + +## Context + +See proposal.md — Why. The design-relevant starting points, measured: + +**The declarative notification engine already exists and already serves +non-object entities.** `AnnotationNotificationDispatcher::dispatch()` +(`lib/Service/Notification/AnnotationNotificationDispatcher.php:177`) +resolves a schema from an `ObjectEntity` and delegates to +`dispatchWithSchema()` (`:211`), which is PUBLIC. That second entry point is +how OpenRegister's own system entities — register, schema, configuration, +source, agent, webhook — get declarative notifications without being +objects: `SystemSchemaRules` (`lib/Service/Notification/SystemSchemaRules.php:43-48`, +`:58`) holds in-code `x-openregister-notifications` rules per synthetic +slug, `SystemEntityObjectAdapter extends ObjectEntity` +(`lib/Service/Notification/SystemEntityObjectAdapter.php:46`) wraps the +entity, and `SystemEntityNotificationListener::handle()` +(`lib/Listener/SystemEntityNotificationListener.php:94-127`) puts the two +together. The seam is proven; a task is the seventh entity through it. + +**The dialect already addresses transition actions.** +`AnnotationNotificationDispatcher::matches()` (`:1523-1539`) supports +`trigger: {type: 'transition', action: }` against +`$context['action']`. `flow-task-entity` records `last_action` for exactly +this reason. + +**The dialect cannot yet express a decision.** `VALID_ACTION_TARGET_KINDS` +is `['object-detail', 'route', 'url']` +(`lib/Service/Notification/NotificationAnnotationValidator.php:60`), and +`AnnotationNotifier::addDeclaredActions()` renders every one of them +`->setLink($url, 'GET')` (`lib/Notification/AnnotationNotifier.php:235`). +`MAX_ACTIONS` is 2 (`NotificationAnnotationValidator.php:68`). +`VALID_CHANNELS` is +`['nc-notification','email','activity','webhook','talk','web-push']` (`:53`) +and `VALID_RECIPIENT_KINDS` is +`['users','field','groups','relation','object-acl','expression']` (`:51`). + +**The CalDAV leaf is a full VTODO store.** `lib/Service/TaskService.php` +(753L) does CRUD over `OCA\DAV\CalDAV\CalDavBackend`: `createTask()` writes +the VTODO body at `:400-440` (`SUMMARY`, `DESCRIPTION`, `STATUS`, +`PRIORITY`, `DUE`, `X-OPENREGISTER-REGISTER/SCHEMA/OBJECT`, a base64 +`X-OPENREGISTER-DATA` blob, an RFC 9253 `LINK` — no `URL`); +`vtodoToArray()` (`:595-624`) reads it back; `findUserCalendar()` +(`:561-582`) picks the SESSION user's first VTODO-supporting calendar; +`getAllUserTasks()` (`:120-197`) walks every calendar and filters in PHP; +`buildTaskDeepLink()` (`:352-364`) builds a Tasks-app hash link, i.e. a link +INTO the calendar rather than back to a task. +`CalDavVtodoObjectSourceProvider::toObjectEntity()` +(`lib/Service/ObjectSource/CalDavVtodoObjectSourceProvider.php:243-266`) +projects a VTODO into an `ObjectEntity` and merges the +`X-OPENREGISTER-DATA` blob's fields, and `TasksProvider::storageStrategy()` +returns `'link-table'` (`lib/Service/Integration/BuiltinProviders/TasksProvider.php:122`). +The `nc-task` virtual schema (`lib/Repair/SeedAppVirtualSchemas.php:100`) +projects the acting user's whole task list read-only. + +**Nextcloud offers two write-back hooks.** A Sabre `ServerPlugin` declared +in `appinfo/info.xml` under `` is loaded by +`apps/dav/lib/AppInfo/PluginManager.php:155-160`; and `apps/dav` emits +`CalendarObjectCreatedEvent` / `CalendarObjectUpdatedEvent` / +`CalendarObjectDeletedEvent` plus their `Cached*` variants +(`apps/dav/lib/Events/`). They differ in when they fire, which is the whole +design of D-6. + +**Constraint from the chain:** `flow-task-entity` lands first and owns the +table, the verbs, the authorization and the inbox QUERY. Nothing in this +change may re-decide any of them. + +## Goals / Non-Goals + +**Goals:** +- One-directional truth stated as a mechanism, not a convention: a + projection that drifts should be structurally unable to win. +- Reuse the declarative notification engine end to end; add exactly one + concept to the dialect and justify it. +- A binary approval answerable in one tap from the notification, with the + same authorization as any other surface. +- Write-back that fails CLOSED and is legible when it does — the user is + told, not silently corrected. +- Delivery that cannot take the engine down with it. + +**Non-Goals (design-level, on top of the proposal's scope):** +- No new notification transport. `email`, `talk`, `web-push`, `activity` and + `webhook` are existing channels and are configured, not implemented, here. +- No calendar SHARING model. A projection goes into the assignee's own + calendar; delegating visibility of that calendar is Nextcloud's feature, + not this change's. +- No offline conflict resolution beyond last-writer-refused. A client that + queues an unauthorized completion for a week gets a refusal a week later. +- No redesign of `CnFlowRunsWidget`. The tasks widget is modelled on it + (`nextcloud-vue/src/components/CnFlowRunsWidget/CnFlowRunsWidget.vue`, + 559L) and does not refactor it. +- No re-derivation of "due soon" or "escalated". Those events are computed + by `flow-business-timers`; this change delivers them. + +## Decisions + +### D-1 — Declarative-vs-imperative decision (ADR-031) + +**Notifications here are DECLARATIVE with no exception. The calendar +projection and the write-back gate are imperative, and each is imperative +for a reason that is about protocol and trust, never about business rules.** + +ADR-031's test is: if an `x-openregister-*` extension can express the +requirement, declare it rather than write a service. Applied surface by +surface: + +**Notifications — declarative, fully.** Nothing in this change calls +`INotificationManager` for a task. Task delivery is a rule set in the +canonical `x-openregister-notifications` dialect, evaluated by +`AnnotationNotificationDispatcher`, rendered by `AnnotationNotifier`, +respecting `NotificationPreferenceService`, the queue +(`lib/BackgroundJob/NotificationQueueFlushJob.php`), the dedupe state and +the digest schedule — because it is the same rule set the rest of the +platform uses. Concretely: + +- **who**: `recipients` with `kind: users` / `groups` / `field` / + `expression` over the task's performer model. Nothing bespoke. +- **when**: `trigger: {type: 'transition', action: [...]}` naming the verbs + `flow-task-entity` records, matched at + `AnnotationNotificationDispatcher.php:1523-1539`. +- **due / overdue**: `trigger: {type: 'scheduled', intervalSec, filter}` + using the operator-object filter grammar verified in production at + `shillinq/lib/Settings/register.d/contract-lifecycle-management.json:383-401`. +- **where**: `channels`, from the six the validator already accepts + (`NotificationAnnotationValidator.php:53`). + +The declarative choice is not decoration. It is what makes a task +notification obey the user's per-rule preferences, dedupe, digest and +localisation without any of that being reimplemented — and it is what lets +`flow-approval-consolidation` retune fleet task delivery by editing rules +instead of shipping PHP. + +**The one imperative in the notification path — and its fence.** A task is +not an OR object, so the dispatcher needs an `ObjectEntity` and a `Schema` +to work with. Both are produced in code: `TaskNotificationRules` (a +`SystemSchemaRules` clone, `SystemSchemaRules.php:58`) and a task adapter (a +`SystemEntityObjectAdapter` clone, `:46`). That is the same +imperative-adapter-plus-declarative-rules split ADR-031 already sanctioned +for system entities, and it is fenced by one rule: **the adapter may contain +no notification logic.** It maps task columns and derived fields onto a flat +`ObjectEntity` payload and stops. Every question of who, when, which channel +and what text is answered by a rule. + +*Alternative rejected:* making the task an OR object so schema-declared +rules apply directly. `flow-task-entity` D-2 already rejected that for +storage reasons (pooled-query cost, atomic claim, ACL-vs-performer +authorization). Reversing it just to reach the dialect would trade a +correctness property for a plumbing convenience — and the adapter costs +about eighty lines. + +**The dialect extension — one new action target kind.** The dialect can +express everything above EXCEPT a decision, because every declared action +renders `setLink($url, 'GET')` (`AnnotationNotifier.php:235`). One new +target kind, `task-verb`, names a lifecycle verb and outcome and renders +`setLink($url, 'POST')`. This is a genuinely new capability of the dialect, +not a workaround: it is declared in the rule, validated by +`NotificationAnnotationValidator`, and available to every future rule. It is +also deliberately narrow — the target names a VERB, never a URL the author +composes, so a rule cannot use a notification button to POST anywhere it +likes. *Alternative rejected:* an imperative task notifier that builds +actions in PHP. It would work, and it would make task notifications the one +family in the platform that ignores user preferences and dedupe, and would +leave the dialect still unable to express a decision for anyone else. + +**The calendar projection — imperative, because iCalendar is a wire +format.** Building a VCALENDAR/VTODO body, negotiating `CalDavBackend`, +picking a calendar, and issuing a whole-object PUT are protocol mechanics. +ADR-031 keeps exactly this category in PHP. There is no schema extension +that emits an ICS blob, and inventing one would be a serialization format +described in JSON. + +**The write-back gate — imperative, because it is a trust boundary.** The +gate's whole job is to distrust its input: verify the task identity, verify +the transition is legal, verify the caller, and refuse otherwise. A +declarative guard evaluates over data that is assumed well-formed; this code +runs before that assumption holds. It is also fail-closed by construction +(ADR-005): unknown property shape, unresolvable task, unavailable +authorization service — every one denies. + +**Derived, never stored — restated because a projection is where it would +regress.** `overdue` has no column (`flow-task-entity`), and the scheduled +rule filters the derived predicate. The temptation this change creates is +specific: writing `overdue` into the projection payload so a scheduled rule +can filter it cheaply. The projection payload is BUILT per evaluation from +the same derivation the inbox uses, so there is no stored copy to go stale — +the mistake decidesk's `actionOverdue` +(`decidesk/lib/Settings/decidesk_register.json:4173-4180`) is still making. + +### D-2 — The projection sync contract, in full + +Stated here in full because "one-directional truth" is the entire point of +the change and is otherwise the kind of principle a later PR erodes one +convenience at a time. + +**1. One writer.** `openregister_tasks` and its audit are the only store. +Every projection is a pure function of a task record plus the derivations +(`overdue`, `days_until_due`, display title) that `flow-task-entity` already +defines. No projection holds a field that cannot be reproduced from the +task. + +**2. Rebuild, never merge.** A projection is RENDERED from the task, not +diffed against its previous self and merged. Reconciliation compares the +rendered target with what is there and makes what is there match. Nothing in +a projected surface is treated as an authority to preserve. + +**3. Exactly one path back, and it carries requests.** The write-back gate +is the only path from a projection to the engine. What crosses it is a +`(task_uuid, requested_verb, actor)` triple — never a state, never a field +value. The engine decides whether that verb is legal and permitted. This is +the rule that stops `STATUS:COMPLETED` from being able to SET a state: +it can only REQUEST `complete`. + +**4. Refusal is visible.** A refused request leaves the engine unchanged, +the projection restored to the engine's truth, an audit denial recorded, and +the actor notified. There is no code path that reverts silently. A calendar +entry that quietly comes back reads as a sync bug and trains users to +distrust the surface. + +**5. Identity is carried, not inferred.** Every projected VTODO carries +`X-OPENREGISTER-TASK` (the task uuid). A VTODO without it is not this +capability's business at any point in its life. There is no heuristic +matching on summary, due date or calendar. + +**6. Rendering is idempotent and non-reflexive.** Rendering twice with no +change is a no-op. A write the projector performs is tagged so the gate does +not read it back as a user edit, and a gate-driven transition's re-render +cannot re-enter the gate. The tag is a rendered-content hash held on the +task's projection state, so an echo is detectable even when the write +arrives through a path that did not preserve an in-process flag. + +**7. Delivery is best-effort; the lifecycle is not.** A projection failure +is logged and retried and NEVER rolls back the transition that caused it +(D-8). + +**8. Divergence is detectable.** The projection state per task records what +was rendered and when. Reconciliation is therefore a comparison, not a +guess, and "is anything drifted?" is answerable without reading every +calendar. + +The measured reason all eight are written down rather than assumed: +`hermiq/src/manifest.json:1240` — a page that "used to declare +hermiq/agentflow — a duplicate mirror of the native flow rows — so the list +showed objects while the engine ran the native rows, free to drift". That +mirror was read-only to users. A calendar is not. + +### D-3 — Notifications ride `dispatchWithSchema()`, not a new pipeline + +`TaskNotificationRules` publishes the rule set under a synthetic slug; +`TaskNotificationListener` listens to the task lifecycle events +`flow-task-entity` emits, wraps the task in `TaskObjectAdapter extends +ObjectEntity`, and calls `dispatchWithSchema(object:, trigger:, context:, +schema:)` with `context['action']` set to the recorded transition action. +Copied wholesale from `SystemEntityNotificationListener.php:94-127`. + +Two consequences worth being explicit about: + +- **Dedupe keying.** `NotificationDedupeState` is keyed per object today. + The adapter's uuid IS the task uuid, so a task is a stable dedupe subject + without changing the mapper. This is why the adapter must expose the task + uuid as the entity uuid and not, say, the subject object's. +- **Recipient resolution.** `kind: field` (the kind decidesk's + `actionItemAssignedToYou` uses) reads a field off the payload, so the + adapter must flatten `assignee`, `candidate_users`, `candidate_groups`, + `requester` and `watchers` into readable payload fields. Pool recipients + use `kind: groups` with the group list resolved from the payload rather + than hardcoded — the pool is per task, unlike decidesk's static + `decidesk-members`. + +*Alternative rejected:* emitting synthetic object events so the existing +`AnnotationNotificationListener` picks tasks up. It would put non-objects on +the object event bus and every other object listener would have to learn to +ignore them. + +### D-4 — Withdrawal, not just delivery + +An actionable notification is a promise that the buttons still mean +something. `NotificationService.php:228` already uses +`IManager::markProcessed()`; the task listener uses the same call on +terminal transitions and on assignee change. + +The case that makes this non-optional is the pooled task: four people are +notified, one claims, and the other three are holding approve buttons for +work that is no longer theirs. Without withdrawal the second click gets a +conflict — correct, and a bad experience that reads as a broken system. +Cancellation-by-propagation (`flow-task-entity` D-8) is the same problem +arriving from the engine side. + +### D-5 — What is projected, into whose calendar, and what it carries + +**Whose calendar.** The ASSIGNEE's first VTODO-supporting calendar, not the +session user's. `findUserCalendar()` (`lib/Service/TaskService.php:561-582`) +resolves from `IUserSession` today; the projection needs the same resolution +parameterised by uid. This is the single most consequential difference from +the current leaf: a task assigned by A to B must land in B's calendar, and +the code that exists resolves A's. + +**Pooled tasks are not projected.** A VTODO lives in one calendar. There is +no calendar for "whoever claims it", and projecting into every member's +calendar would assert four assignments the engine has not made and then have +to retract three. Pooled work is delivered by notification and by the inbox; +the calendar entry appears on claim. + +**What the VTODO carries** (rendered by the projector, read by the gate): + +| VTODO property | Source | Note | +|---|---|---| +| `UID` | task uuid | stable across reassignment | +| `SUMMARY` | task display title | synthesized where `title` is null, never persisted back | +| `DESCRIPTION` | task description | assignee prose REMOVED | +| `DUE` | `due_at` | advisory; `expires_at` is not projected as `DUE` | +| `PRIORITY` | normalised priority → iCal 0-9 | the inverse of the import mapping `flow-task-entity` publishes | +| `STATUS` | lifecycle state → the four VTODO values | lossy by construction, see D-2 rule 3 | +| `URL` | task form deep link | **new**; today no `URL` is emitted at all (`:400-440`) | +| `X-OPENREGISTER-TASK` | task uuid | **new**; the identity the gate keys on | +| `X-OPENREGISTER-TASK-ASSIGNEE` | assignee uid | **new**; replaces `'Assigned to: '` prose | +| `X-OPENREGISTER-REGISTER/SCHEMA/OBJECT` | the task's anchor | unchanged shape, so the existing read path still resolves the subject | +| `LINK` | subject object API URI | unchanged (RFC 9253) | + +`URL` and `LINK` do different jobs and both are kept: `LINK` points at the +subject object's API resource (machine), `URL` points at the task form +(human). The existing `buildTaskDeepLink()` (`:352-364`) builds a link into +the Tasks app — useful for the standalone leaf, and exactly backwards for a +projection, which is trying to get the user OUT of the calendar and into the +task. + +**Assignee as a real identity.** `extractAssigneeFromDescription()` +(`:258-264`) returns `substr($description, strlen('Assigned to: '))`, and +`CnTasksTab.vue:304` writes that prefix plus a DISPLAY NAME — so the value +never round-trips to an account and two people sharing a display name are +indistinguishable. The docblock at `:112` claims the filter "matches +ATTENDEE or description"; no code in the file reads `ATTENDEE`. The +projection carries `X-OPENREGISTER-TASK-ASSIGNEE` as the uid. Whether to +ALSO emit a standards-track `ATTENDEE` (RFC 5545 §3.8.4.1, valid on VTODO) +is deferred — see Open Questions; it changes no requirement either way, +because the requirement is "machine-readable identity", not a property name. + +### D-6 — Write-back: refuse in-band first, revert second + +Two hooks, used for different reasons, both required. + +**Primary — a Sabre `ServerPlugin`** declared in `appinfo/info.xml` under +``, loaded by +`apps/dav/lib/AppInfo/PluginManager.php:155-160`. It subscribes to +`beforeWriteContent` / `beforeUnbind`, parses the incoming ICS, and acts +only when `X-OPENREGISTER-TASK` is present. It maps the requested change to +a verb, calls the entity `TaskService`, and on refusal throws a DAV +forbidden exception. The client gets a 403 on the PUT and **never records +the change**. This is strictly better than reverting: there is no window in +which the user's client believes the task is done. + +**Secondary — a listener on `CalendarObjectUpdatedEvent` / +`CalendarObjectDeletedEvent`** (`apps/dav/lib/Events/`), which catches +writes that reached the backend without traversing this plugin — another +app calling `CalDavBackend` directly, or a future DAV path change. Here the +write has committed, so the response is REVERT: re-render the projection +from the task and notify the actor. + +*Alternative rejected — event listener only.* Simpler, one hook, and it +means every unauthorized tick is a revert. Clients that have already synced +show the task done and then undone; on a phone that reads as data loss. +*Alternative rejected — plugin only.* Leaves the direct-backend path +unguarded, which is precisely the path a future refactor takes. + +**What the gate does NOT do.** It does not interpret `SUMMARY`, +`DESCRIPTION`, `DUE` or `PRIORITY` edits. Those are projection-owned; the +next render overwrites them (D-2 rule 2). Only `STATUS` (and deletion) name +verbs. This keeps the trust boundary one field wide. + +**Echo suppression.** Rule 6 of D-2: the projector records a hash of what it +rendered on the task's projection state; the gate compares an incoming +body's relevant fields against it and treats a match as its own echo. A +flag on the request would not survive the async listener path. + +### D-7 — `TaskService`'s authority inverts; the file survives + +`lib/Service/TaskService.php` (753L) is not deleted and not rewritten. Its +authority changes: + +| Method | Before | After | +|---|---|---| +| `createTask()` (`:384`) | creates the task | renders a projection; refuses an engine-identity payload from the sub-resource endpoint | +| `updateTask()` (`:465`) | changes the task | for a projected VTODO, routes through the gate; for a standalone VTODO, unchanged | +| `deleteTask()` (`:540`) | deletes the task | for a projected VTODO, does not cancel the engine task; projection is restored | +| `getTasksForObject()` (`:280`) | the answer | still the answer for standalone VTODOs; engine tasks come from the inbox | +| `getAllUserTasks()` (`:120`) | the user's task list | retired as the aggregate's implementation; `GET /api/tasks` (`appinfo/routes.php:903`) answers from the inbox | +| `findUserCalendar()` (`:561`) | session user's calendar | parameterised by uid; session user for standalone, assignee for projections | + +The two-class split (standalone vs projected, keyed on +`X-OPENREGISTER-TASK`) is what lets both live in one file without the file +having two personalities: every branch asks one question. + +Left alone deliberately: +`CalDavVtodoObjectSourceProvider` (`:243-266`) and the `nc-task` virtual +schema (`lib/Repair/SeedAppVirtualSchemas.php:100`) project the acting +user's own calendar read-only. A projected VTODO showing up there is +correct — it IS in the user's calendar — and it is read-only, so it cannot +become a second write path. `TasksProvider::storageStrategy()` stays +`'link-table'` (`:122`). + +*Alternative rejected — a second service and leave the leaf frozen.* Two +writers to one calendar, no shared notion of which VTODOs are whose, and the +`'Assigned to: '` prose survives in the surface users actually touch. + +### D-8 — Delivery failure is isolated from the transition + +Projection and notification run AFTER the lifecycle transaction commits, not +inside it. A failure is logged with the task uuid and the failing surface, +and left to reconciliation (D-2 rule 8). + +The alternative — projecting inside the transaction so the task and its +calendar entry are atomic — is what would let a calendar backend outage +block an approval chain. The asymmetry is deliberate: a task that exists +with no calendar entry is recoverable by reconciliation; a task that could +not be created because a calendar was down is an outage in the wrong system. +This is also why "assignee has no VTODO-capable calendar" is a skip and a +log, not the exception `findUserCalendar()` throws today (`:576`, +`NoVtodoCalendarException`). + +### D-9 — The inbox surfaces, and a one-app leaf repoint + +**`CnTasksWidget`** is modelled on `CnFlowRunsWidget` (559L) — same shell, +same loading/empty/error states, same row idiom — reading the inbox API with +its server-side filter, sort, page and total. The badge reads the TOTAL. +`flow-task-entity` D-9 makes filtering-in-the-datastore a spec requirement +for the query; the widget's obligation is to not undo it by filtering the +page it received. + +**The leaf repoint** moves `nextcloud-vue/src/integrations/builtin/tasks.js` +(64L), `CnTasksTab.vue` (500L), `CnTasksCard.vue` (387L) and +`src/types/task.d.ts` (31L) from the VTODO sub-resource endpoints +(`appinfo/routes.php:906-909`) onto tasks-by-object. `TTaskStatus` widens +from `needs-action | in-process | completed | cancelled` (`task.d.ts:5`) to +the six CMMN states; `isOverdue` becomes a server-derived field rather than +a client comparison; the assignee becomes an identity; and the tab offers +only the verbs the caller may invoke. + +**Blast radius is one app.** `decidesk/src/manifest.json:594` is the only +manifest in the fleet mounting `integrationId: "tasks"`. So the leaf's +contract can change in one coordinated step instead of behind a +compatibility shim — and decidesk is also the owner of both notification +rules being generalised, so it is one conversation, not two. + +## Seed Data (ADR-001) + +The declarative rule set IS the seed data for this change, plus fixtures +that exercise the projection. All uids are obviously fake and all uuids are +nil placeholders. + +**1. Assigned to you — actionable, binary.** The generalisation of +decidesk's `actionItemAssignedToYou` +(`decidesk/lib/Settings/decidesk_register.json:4202-4241`): same channels, +recipient resolved from the task's own assignee field, and two `task-verb` +actions instead of one navigation link. + +```json +{ + "taskAssignedToYou": { + "trigger": { "type": "transition", "action": ["assign", "claim"] }, + "enabled": true, + "channels": ["nc-notification", "web-push"], + "recipients": [{ "kind": "field", "field": "assignee" }], + "subject": { + "nl": "Aan jou toegewezen: {{title}}", + "en": "Assigned to you: {{title}}" + }, + "actions": [ + { + "label": { "nl": "Goedkeuren", "en": "Approve" }, + "primary": true, + "target": { "kind": "task-verb", "verb": "complete", "outcome": "approved" } + }, + { + "label": { "nl": "Afwijzen", "en": "Reject" }, + "target": { "kind": "task-verb", "verb": "complete", "outcome": "rejected" } + } + ] + } +} +``` + +The reject action routes to the form rather than completing directly, +because `flow-task-entity` makes a comment mandatory on a rejecting +outcome — the spec requires that routing, and the rule does not have to +know it. + +**2. Offered to your pool — no assignee to address.** +`trigger: {type: 'transition', action: 'offer'}`, recipients +`kind: groups` resolved from the task's candidate groups, one navigation +action to the pooled inbox. Exercises the recipient path that +`kind: field` cannot serve. + +**3. Overdue — the derived filter, in the verified grammar.** Modelled on +`shillinq/lib/Settings/register.d/contract-lifecycle-management.json:383-401`: + +```json +{ + "taskOverdue": { + "trigger": { + "type": "scheduled", + "intervalSec": 86400, + "filter": { + "all": [ + { "field": "isTerminal", "operator": "notIn", "values": [true] }, + { "field": "dueAt", "operator": "before", "value": "now" } + ] + }, + "dedupeFields": ["taskUuid"] + }, + "enabled": true, + "channels": ["nc-notification"], + "recipients": [{ "kind": "field", "field": "assignee" }], + "subject": { + "nl": "Taak over tijd: {{title}}", + "en": "Task overdue: {{title}}" + } + } +} +``` + +No `overdue` field appears anywhere in it. + +**4. Cancelled by propagation** — `trigger.action: 'cancel'`, recipient the +former assignee, no actions, and it is the fixture that proves withdrawal +(D-4) fires from the engine side. + +**5. Projection fixtures**: one assigned task with a calendar projection +(assignee `EXAMPLE_APPROVER_USER`, task uuid +`00000000-0000-0000-0000-000000000002`); one pooled task with NO projection; +one task whose assignee has no VTODO-capable calendar, so the skip path has +a fixture rather than only a test double. + +Seeds install through the existing seeding path and are idempotent. + +## Migration Plan + +1. **Dialect first, backwards compatible.** Add the `task-verb` action + target kind to `NotificationAnnotationValidator` and the POST render to + `AnnotationNotifier`. Existing rules are untouched: the three existing + kinds keep rendering GET. Rollback is removing the kind; no stored rule + uses it yet. +2. **Adapter, rules, listener.** Task notifications begin. Nothing about the + calendar has changed and nothing writes back. Rollback is disabling the + listener; tasks keep working, silently, as they did in + `flow-task-entity`. +3. **Projection write path.** The projector renders VTODOs for assigned + tasks; projection state is recorded. Still no write-back — the calendar + is strictly read-only-in-effect, because any edit is simply overwritten + on the next render. This is the safest possible intermediate state and it + is worth pausing in. +4. **Write-back gate.** Sabre plugin plus the event listener. This is the + step that opens the trust boundary, so it ships alone and with the audit + assertions already green. +5. **Aggregate and leaf.** `GET /api/tasks` (`appinfo/routes.php:903`) + switches to the inbox; the nc-vue leaf repoints. Coordinated with + decidesk, the only mounter (`decidesk/src/manifest.json:594`). The + sub-resource VTODO endpoints (`:906-909`) keep serving standalone tasks + throughout. +6. **No data migration.** Existing VTODOs carry no `X-OPENREGISTER-TASK`, so + every one of them is standalone by definition and keeps its current + behaviour. Engine tasks are new. There is nothing to backfill and nothing + to reclassify — which is the payoff for keying the two classes on a + property that did not previously exist. + +Rollback at any step above 3 leaves projected VTODOs in calendars. They are +inert: no `X-OPENREGISTER-TASK` handler means they behave as ordinary tasks. +A cleanup command removes them by property. + +## Risks / Trade-offs + +- **A POST from a notification is a state change from a surface with weak + CSRF context** → It lands on the same authenticated controller route as + any other call and is authorized by `TaskAuthorizationService` + identically; the target names a VERB, not an author-supplied URL, so a + rule cannot aim it. The notification is a transport with no privileges of + its own, and the spec requires a denial to be audited like any other. +- **A user's calendar is shared, so someone else can tick the task off** → + Exactly the case the gate exists for: the acting DAV identity is checked + against the task's performer model, refused in-band by the Sabre plugin, + and reverted-plus-notified where it committed. This is the single most + likely real-world unauthorized path and it has an e2e scenario. +- **Losing the VTODO status vocabulary's expressiveness** (four values for + six states) → Accepted and made safe by D-2 rule 3: the mapping is lossy + only in the render direction, and an incoming status names a TRANSITION + that the engine may refuse. The calendar can under-describe a task; it can + never mis-set one. +- **Reassignment churns calendars** — a task reassigned three times deletes + and creates three VTODOs, and a client that synced in between may show a + stale entry → Bounded by carrying a stable `UID` and by reconciliation. + The alternative (leave it in the old assignee's calendar) is worse: it + shows someone work that is not theirs. +- **Notification volume on a busy pool.** Offering to a 40-person group + produces 40 notifications, and claiming withdraws 39 → Mitigated by the + existing dedupe/digest machinery this change deliberately rides rather + than bypasses; a rule can move a pool offer to a digest without code. +- **A projection state column is one more thing that can drift from the + task** → It is written by the projector only, holds only a rendered-content + hash and a timestamp, and is never read by any lifecycle or authorization + rule. If it is wrong the worst outcome is a redundant re-render. +- **Two hooks into DAV double the surface a Nextcloud upgrade can break** → + Accepted (D-6): the plugin is the good path and the listener is the safety + net, and they share one gate implementation, so an upgrade breaking one + degrades behaviour rather than opening the boundary. A test asserts the + gate is reached by both. +- **The leaf repoint is a breaking API change for decidesk** → One app, one + manifest line (`decidesk/src/manifest.json:594`), sequenced last, and + decidesk owns both rules being generalised. Coordinated, not shimmed. + +## Open Questions + +- Whether the projected VTODO should ALSO carry a standards-track `ATTENDEE` + (RFC 5545 §3.8.4.1, valid on VTODO) alongside + `X-OPENREGISTER-TASK-ASSIGNEE`. Deferrable: the spec requires a + machine-readable identity, not a property name, so either satisfies it. + The answer depends on measuring what the Tasks app and common third-party + clients actually do with `ATTENDEE` on a VTODO, which is an observation to + make against the running projection rather than a decision to guess now. + It changes no requirement, no task, and no other decision. +- Reconciliation cadence — whether drift detection runs as a periodic sweep, + on read, or both. It changes neither the contract (D-2) nor the task + breakdown, and the right interval is a function of observed drift once + there is a projection to observe. diff --git a/openspec/changes/flow-task-inbox-projections/proposal.md b/openspec/changes/flow-task-inbox-projections/proposal.md new file mode 100644 index 0000000000..67ec3e020e --- /dev/null +++ b/openspec/changes/flow-task-inbox-projections/proposal.md @@ -0,0 +1,252 @@ +--- +kind: code +depends_on: [flow-task-entity] +--- + +# Proposal: flow-task-inbox-projections + +## Summary + +Deliver the engine task to where people already work: a Nextcloud +notification when work arrives (**actionable** — approve and reject are +buttons, not a link to a page), a VTODO in the assignee's own calendar +carrying a `URL` back to the task form, a write-back listener so ticking +that VTODO off completes the engine task, and the inbox surfaces that make +"what is waiting for me?" a widget and a tab rather than an API call. + +Every one of these is a **PROJECTION**. `openregister_tasks` is the single +truth; the calendar, the notification and the widget are read models that +are rebuilt from it and never read back into it — except through one +authorizing gate that treats what arrives as untrusted input. ADR-098 +Decision 2 is delivery on Nextcloud's own entities, and the reason it is a +projection and not a second store is measured, not aesthetic. + +## Why + +**`flow-task-entity` makes tasks exist. It explicitly notifies nobody and +appears in no calendar** — its own acceptance criteria say so ("Nothing in +this change sends a notification, writes a VTODO..."). A queryable inbox +that nothing pushes to is an inbox users have to remember to open. + +**The one actionable notification the fleet has cannot act.** decidesk's +`actionItemAssignedToYou` +(`decidesk/lib/Settings/decidesk_register.json:4202-4241`) is the only rule +in the fleet that declares `actions`, and its single action is +`target: {kind: 'object-detail'}` — an "open the item" link. It could not be +anything else: `AnnotationNotifier::addDeclaredActions()` renders every +declared action as `->setLink($url, 'GET')` +(`lib/Notification/AnnotationNotifier.php:235`), hardcoded. So the notifier +can offer to NAVIGATE and can never offer to DECIDE. A binary approval +delivered as a link costs a page load and a second decision point; delivered +as two buttons it costs one tap. The validator already caps `MAX_ACTIONS` +at 2 (`lib/Service/Notification/NotificationAnnotationValidator.php:68`) — +exactly approve and reject. + +**The overdue notification the fleet ships fires on a value somebody had to +remember to write.** decidesk's `actionOverdue` +(`decidesk_register.json:4173-4180`) is +`trigger: {type: scheduled, intervalSec: 86400, filter: {taskStatus: "overdue"}}`. +`overdue` is a clock-derived fact stored as a status value; between writes it +is wrong, and `flow-task-entity` forbids storing it at all. The filter has to +be re-expressed against a derived predicate or the notification silently +covers only the tasks something stamped. + +**In the calendar, "who owes this" is a substring of a free-text field.** +`TaskService::extractAssigneeFromDescription()` +(`lib/Service/TaskService.php:258-264`) returns +`substr($description, strlen('Assigned to: '))` when the description starts +with that literal — the prefix `CnTasksTab.vue:304` writes +(`nextcloud-vue/src/components/CnObjectSidebar/CnTasksTab.vue:304`: +`taskData.description = 'Assigned to: ' + this.newTaskAssignee.displayName`). +It stores a **display name**, so it does not even round-trip to a uid. The +docblock one method up claims the filter "matches ATTENDEE or description" +(`lib/Service/TaskService.php:112`); no code path in the file reads +`ATTENDEE`. `createTask()` emits `SUMMARY`, `DESCRIPTION`, `STATUS`, +`PRIORITY`, `DUE`, three `X-OPENREGISTER-*` properties, a base64 +`X-OPENREGISTER-DATA` blob and an RFC 9253 `LINK` +(`lib/Service/TaskService.php:400-440`) — and no `URL`, so the VTODO deep- +links back to the object **API endpoint**, not to anything a human can open. + +**And the aggregate that stands in for an inbox does not scale and cannot +express the lifecycle.** `getAllUserTasks()` +(`lib/Service/TaskService.php:120-197`) iterates every calendar, calls +`getCalendarObjects()` per calendar, `getCalendarObject()` per row, `strpos` +pre-filters, parses with `Sabre\VObject\Reader`, then filters status and +assignee **in PHP** and sorts **in PHP**. `openspec/specs/object-interactions/spec.md` +concedes the outcome in a scenario named "Performance degradation warning" +(10,000+ objects MAY exceed 2 seconds). Its vocabulary is VTODO's four +values — `needs-action | in-process | completed | cancelled` +(`nextcloud-vue/src/types/task.d.ts:5`) — which cannot express the six CMMN +states, cannot express a pooled task with no assignee, and cannot express a +candidate group, because a VTODO lives in exactly one person's calendar. + +**Why a projection and not a mirror.** The fleet has already paid for the +alternative and wrote the post-mortem into a manifest: +`hermiq/src/manifest.json:1240` records that a page "used to declare +hermiq/agentflow — a duplicate mirror of the native flow rows — so the list +showed objects while the engine ran the native rows, **free to drift**". A +calendar the user can edit is a far more writable second store than that +one was. So the contract has to be stated as a requirement and not left as +an intention: truth flows one way, and the one path back is a gate. + +## What Changes + +- **Task notifications are DECLARATIVE, through the dispatcher that already + exists.** A `TaskNotificationRules` registry modelled exactly on + `lib/Service/Notification/SystemSchemaRules.php` publishes + `x-openregister-notifications` rules for the task entity; a listener on + task lifecycle events adapts the task into a transient `ObjectEntity` and + calls `AnnotationNotificationDispatcher::dispatchWithSchema()` + (`lib/Service/Notification/AnnotationNotificationDispatcher.php:211`). + This is not a new pipeline: it is the seam + `SystemEntityNotificationListener` (`lib/Listener/SystemEntityNotificationListener.php:94-127`) + already uses with `SystemEntityObjectAdapter` + (`lib/Service/Notification/SystemEntityObjectAdapter.php:46`) to give + registers, schemas, sources, agents and webhooks declarative notifications + without any of them being an OR object. Tasks are the sixth such entity, + not a special case. +- **Rules address the named transition ACTION, not the resulting state.** + `matches()` already supports `trigger: {type: 'transition', action: ...}` + including an array of actions + (`AnnotationNotificationDispatcher.php:1523-1539`). `flow-task-entity` + stores `last_action` precisely so this works. Delivered events: offered to + your pool, assigned to you, reassigned away from you, due soon, escalated, + cancelled by propagation. +- **Actionable notifications gain a decision target — the only new dialect + surface.** `VALID_ACTION_TARGET_KINDS` is `object-detail | route | url` + (`NotificationAnnotationValidator.php:60`), all rendered `GET`. A fourth + kind, `task-verb`, names a lifecycle verb and its outcome; the notifier + renders it `->setLink($url, 'POST')`. Two of them are approve and reject, + which is what `MAX_ACTIONS = 2` already allows. The POST lands on the same + `TaskController` verb a form submission would, and is authorized by + `TaskAuthorizationService` identically — the notification is a transport, + never a bypass. +- **Overdue is notified from the derived predicate.** The `scheduled` rule + uses the operator-object filter grammar verified in production at + `shillinq/lib/Settings/register.d/contract-lifecycle-management.json:383-401` + (`{"all":[{"field":"status","operator":"notIn","values":[...]}, + {"field":"dueDate","operator":"before","value":"now"}]}`) evaluated over the + task projection's derived fields. No rule anywhere filters on a stored + `overdue`. +- **`lib/Service/TaskService.php` becomes the projection WRITER and stops + being the store.** Its 753 lines of VTODO CRUD keep working, but the + authority inverts: `openregister_tasks` is written first, and the VTODO is + rendered from it into the **assignee's** calendar. The projected VTODO + carries `SUMMARY` from the task, `DUE` from `due_at`, `PRIORITY` from the + normalised scale, `STATUS` from the CMMN state through a published + mapping, `X-OPENREGISTER-TASK` (the task uuid — the identity that makes + write-back addressable), a real `X-OPENREGISTER-TASK-ASSIGNEE` uid, and a + `URL` property deep-linking to the task form. `'Assigned to: '` prose is + removed at both ends, `CnTasksTab.vue:304` included. +- **Write-back is a gate, not a sync.** A Sabre `ServerPlugin` registered + through `` in `appinfo/info.xml` — the mechanism + `apps/dav/lib/AppInfo/PluginManager.php:155-160` loads — inspects VTODO + writes carrying `X-OPENREGISTER-TASK`, maps `STATUS:COMPLETED` onto the + `complete` verb, and calls `TaskService` (the entity one), which + authorizes. Authorized: the engine advances and the projection is + re-rendered. Unauthorized or invalid: the write is refused in-band where + the request came through DAV, and where it committed anyway the projection + is **reverted** to the engine's truth and the user is notified why. + Everything arriving this way is untrusted input from a user-editable + store. +- **A tasks widget beside the runs widget.** `CnTasksWidget`, modelled on + `nextcloud-vue/src/components/CnFlowRunsWidget/CnFlowRunsWidget.vue` + (559L), reads the entity change's inbox API with its server-side filtering, + pagination and total, and renders a badge count off the total rather than + off a row count. +- **The nc-vue task leaf is repointed off VTODO onto the task API.** + `nextcloud-vue/src/integrations/builtin/tasks.js` (64L), `CnTasksTab.vue` + (500L), `CnTasksCard.vue` (387L) and `src/types/task.d.ts` (31L) move from + the `/api/objects/{r}/{s}/{id}/tasks` VTODO endpoints + (`appinfo/routes.php:906-909`) onto tasks-by-object, and `TTaskStatus` + widens from the four VTODO values to the six CMMN states plus the derived + overdue flag. **Blast radius is one app**: `decidesk/src/manifest.json:594` + is the only manifest in the fleet that mounts `integrationId: "tasks"`. +- **`GET /api/tasks` (`appinfo/routes.php:903`) keeps its URL and changes its + meaning**: it answers from the engine inbox. The calendar-walking + aggregate retires with it. + +## What does NOT change + +Each of these is a different change and is explicitly OUT of scope: + +- **`flow-task-entity`** — the table, `TaskService` (the entity one), + `TaskAuthorizationService`, `TaskInboxService`, the verbs, the audit and + the inbox QUERY API. This change adds no lifecycle rule and no + authorization rule; it consumes both. Where a projection needs to know + whether something is allowed, it asks. +- **`flow-user-task-node`** — the `openregister.user-task` node and the + suspend/resume wiring. +- **`flow-task-forms`** — the task form itself. This change deep-links TO it + and delivers a two-button decision for the binary case; the structured + completion payload is specified there. +- **`flow-business-timers`** — what "due soon" and "escalated" MEAN: SLA + arithmetic, business days, the escalation matrix, the breach sweep. This + change delivers notifications for those events; it does not compute them. +- **`flow-approval-consolidation`** — migrating the 23 fleet task shapes, + including decidesk's `action-item`, whose schema is today a read-only + `caldav-vtodo` projection (`decidesk_register.json`, + `x-openregister-object-source: {provider: caldav-vtodo, readOnly: true}`). +- **`flow-messaging-nodes`** — the messaging NODES a flow author drops on a + canvas (`openregister.send-notification` and its siblings) are an + author-placed send with an author-chosen recipient. This change is + **automatic task-lifecycle delivery**: nobody places it, and it fires + because a task changed hands. The two share the dispatcher and share + nothing else. A flow that needs to tell somebody something that is not a + task uses that change; a task that needs an owner to know it exists uses + this one. + +The generic object-interactions VTODO leaf — tasks a user creates by hand on +any object, unrelated to any engine task — keeps working. Only its authority +over engine tasks is removed. + +## Capabilities + +### New Capabilities +- `flow-task-projections`: the one-directional-truth contract, declarative + task notifications including actionable decisions, the CalDAV VTODO + projection with its deep link and real assignee, the authorizing write-back + gate, and the inbox surfaces. + +### Modified Capabilities +- `object-interactions`: "Tasks on Objects via CalDAV VTODO", "Task Status + Mapping", "Task Compatibility with Nextcloud Tasks App" and "User-Wide Task + Aggregate Endpoint" change at the requirement level. A VTODO carrying + `X-OPENREGISTER-TASK` stops being an independently editable record and + becomes a projection; the user-wide aggregate stops walking calendars. + +## Impact + +- **Affected specs**: new `flow-task-projections`; delta on + `object-interactions`. +- **Affected code (OpenRegister)**: `lib/Service/TaskService.php` (753L — + inverted from store to projection writer); + new `lib/Service/Task/TaskProjectionService.php`, + `lib/Service/Task/TaskCalendarProjector.php`, + `lib/Service/Notification/TaskNotificationRules.php`, + `lib/Listener/TaskNotificationListener.php`, + `lib/Dav/TaskVtodoWriteBackPlugin.php`, + `lib/Listener/TaskVtodoWriteBackListener.php`; + `lib/Service/Notification/NotificationAnnotationValidator.php` (+1 action + target kind), `lib/Notification/AnnotationNotifier.php:235` (POST render), + `lib/Controller/TasksController.php` + `appinfo/routes.php:903`; + `appinfo/info.xml` gains a `` section. + `lib/Service/ObjectSource/CalDavVtodoObjectSourceProvider.php:253` and the + `nc-task` virtual schema (`lib/Repair/SeedAppVirtualSchemas.php:100`) keep + projecting the user's own calendar and are unchanged. +- **Affected code (nextcloud-vue)**: new `CnTasksWidget`; + `src/integrations/builtin/tasks.js`, + `src/components/CnObjectSidebar/CnTasksTab.vue`, + `src/components/CnTasksCard/CnTasksCard.vue`, `src/types/task.d.ts`. +- **Affected apps**: **decidesk only** — the sole mounter of the tasks leaf + (`decidesk/src/manifest.json:594`) and the owner of the two notification + rules this change generalises. No other app mounts the leaf, so the leaf's + API change is a one-app coordination, not a fleet migration. +- **Depends on**: `flow-task-entity` — a projection with no truth to project + is a second store, which is the thing this change exists to prevent. +- **ADRs**: ADR-098 D2 (delivery on Nextcloud's own entities; projection, not + storage); ADR-031 (declarative-vs-imperative — notifications are the + canonical declarative surface, and every imperative choice here is argued + in design.md); ADR-002 (CalDAV as the calendar substrate); ADR-005 + (fail-closed authorization — the write-back gate re-validates everything); + ADR-001 (seed data). diff --git a/openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md b/openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md new file mode 100644 index 0000000000..ed6a5d242c --- /dev/null +++ b/openspec/changes/flow-task-inbox-projections/specs/flow-task-projections/spec.md @@ -0,0 +1,510 @@ +## Purpose + +Deliver the engine task onto Nextcloud's own surfaces — notifications a +person can decide from, a VTODO in their calendar that links back to the +task, and the inbox widgets that show what is waiting — as PROJECTIONS of +one truth, with exactly one authorizing path back from a user-editable +store into the engine. + +## ADDED Requirements + +### Requirement: Truth flows one way, and the one path back is a gate + +The task record owned by `flow-tasks` SHALL be the only store of a task's +lifecycle state, assignee, deadlines and outcome. Every surface this +capability delivers — Nextcloud notifications, CalDAV VTODOs, the inbox +widget, the object task tab — SHALL be a PROJECTION: derived from that +record, rebuildable from it, and authoritative for nothing. + +No projection SHALL be read as an input to any lifecycle decision. The +single exception is the write-back gate specified below, and it SHALL NOT +be an exception to the rule that the engine decides: what arrives through it +is a REQUEST to perform a named lifecycle verb, evaluated by the same +authorization as any other caller of that verb, and discarded when refused. + +Every projection SHALL be reconstructible from the task record alone. No +field SHALL exist only in a projection: if a projected surface displays it, +the task record or a derivation from the task record SHALL be able to +produce it. + +The reason is measured. `hermiq/src/manifest.json:1240` records a page that +"used to declare hermiq/agentflow — a duplicate mirror of the native flow +rows — so the list showed objects while the engine ran the native rows, free +to drift". A calendar is more writable than that mirror was, so the +one-directional contract is stated as a requirement rather than left as an +intention. + +#### Scenario: A projection destroyed is a projection rebuilt + +- **GIVEN** a task with a calendar projection, whose VTODO is deleted + outright from the calendar +- **WHEN** the projection is next reconciled +- **THEN** the VTODO MUST be recreated with the same content it had +- **AND** the task record MUST be unchanged, including its state and audit +- @e2e exclude covered by projection-reconciliation unit tests + +#### Scenario: An edit that is not a recognised verb does not reach the engine + +- **GIVEN** a projected VTODO whose `SUMMARY` and `DESCRIPTION` are edited + in a calendar client +- **WHEN** the write is processed +- **THEN** the task record's title and description MUST be unchanged +- **AND** the next projection render MUST restore the projected values +- @e2e exclude covered by write-back gate unit tests + +### Requirement: Task lifecycle delivery is automatic and declarative + +The system SHALL notify people about task lifecycle events WITHOUT a flow +author placing a node, a schema author writing a rule per app, or any code +path calling the notification API imperatively for a task. + +Task notification rules SHALL be expressed in the canonical +`x-openregister-notifications` dialect (ADR-031) and SHALL be evaluated by +the same dispatcher that evaluates schema-declared and system-entity rules. +A second notification pipeline for tasks SHALL NOT exist. + +Rules SHALL address the NAMED TRANSITION ACTION recorded by `flow-tasks`, +not the resulting state, so that two actions landing on the same state +(a completion by approval and a completion by rejection) are separately +addressable. + +At minimum the following SHALL be delivered: a task offered to a candidate +pool, a task assigned to a person, a task reassigned away from a person, a +task approaching its deadline, a task escalated, and a task terminated by +propagation. + +Recipients SHALL be resolved from the task's performer model. A task offered +to a candidate pool SHALL notify the pool's members and SHALL NOT notify +anyone outside it. A task with no assignee SHALL NOT produce an +assignee-addressed notification. + +#### Scenario: Assignment reaches the assignee and nobody else + +- **GIVEN** a task assigned to one user, with a second user watching it and a + third unrelated to it +- **WHEN** the assign verb succeeds +- **THEN** the assignee MUST receive the assignment notification +- **AND** the unrelated user MUST receive nothing +- @e2e exclude covered by TaskNotificationRules dispatch unit tests + +#### Scenario: Offering to a pool notifies the pool + +- **GIVEN** a task offered to a candidate group of four members +- **WHEN** the offer verb succeeds +- **THEN** all four members MUST receive the offer notification +- **AND** no notification MUST name an assignee, because there is none +- @e2e exclude covered by TaskNotificationRules dispatch unit tests + +#### Scenario: Two outcomes on one state are separately addressable + +- **GIVEN** two rules, one on the `complete` action and one on the `reject` + action, both of which leave the task in the same terminal state +- **WHEN** each action is performed on a different task +- **THEN** each task MUST trigger only its own rule +- @e2e exclude covered by transition-action matching unit tests + +### Requirement: A binary decision is decidable from the notification + +Where a task's completion is a binary choice, the notification SHALL carry +the choice as ACTIONS the recipient can invoke directly. It SHALL NOT +require the recipient to navigate to a page to express a decision the +notification already stated. + +An action SHALL be able to name a task lifecycle verb and its outcome, and +SHALL be delivered as a state-changing request, not as a navigation link. +The existing navigation action kinds SHALL keep working unchanged. + +A decision action SHALL be authorized by exactly the same rules as the same +verb invoked from any other surface. Possessing the notification SHALL NOT +confer any right the recipient does not otherwise have. + +Where the verb requires a mandatory comment — `flow-tasks` requires one on a +rejecting or returning outcome — a notification action for that verb SHALL +NOT complete the task directly. It SHALL open the surface that can collect +the comment. Silently completing without the mandated comment, or failing +after the user believes they answered, are both forbidden. + +At most two decision actions SHALL be offered on one notification. + +#### Scenario: Approving from the notification completes the task + +- **GIVEN** a task assigned to a user whose completion is a binary approve or + reject, delivered as a notification with both actions +- **WHEN** the assignee invokes the approve action +- **THEN** the task MUST be completed with the approving outcome +- **AND** the task audit MUST record the acting identity as the assignee +- @e2e an assignee approves a task from the notification + +#### Scenario: A decision action confers no authority + +- **GIVEN** a notification delivered to a user who is subsequently removed + from the task's candidate pool +- **WHEN** that user invokes the decision action +- **THEN** the call MUST be denied +- **AND** the task state MUST be unchanged +- **AND** the denial MUST be recorded in the task audit +- @e2e exclude covered by notification-action authorization unit tests + +#### Scenario: A rejection collects its mandatory comment + +- **GIVEN** a notification offering approve and reject on a task whose + rejecting outcome requires a comment +- **WHEN** the recipient invokes reject +- **THEN** the task MUST NOT be completed by that invocation alone +- **AND** the recipient MUST be taken to the surface that collects the + comment +- @e2e exclude covered by notification-action routing unit tests + +### Requirement: A notification for an answered task is withdrawn + +When a task reaches a terminal state, or when its assignee changes, every +outstanding notification about it SHALL be withdrawn from the recipients for +whom it is no longer actionable. + +A decision action invoked against a task that has already been answered +SHALL be refused with a conflict naming the current state, and SHALL NOT +overwrite the recorded outcome. + +Withdrawal SHALL apply to a task terminated by propagation as well as to one +a person completed: a task cancelled because its run stopped MUST NOT leave +approve and reject buttons standing in anyone's notification list. + +#### Scenario: Claiming a pooled task clears the other members' notifications + +- **GIVEN** a task offered to four pool members, each notified +- **WHEN** one member claims it +- **THEN** the other three members' notifications MUST be withdrawn +- **AND** the claiming member MUST retain an actionable notification +- @e2e exclude covered by notification-withdrawal unit tests + +#### Scenario: A stale decision button loses to the recorded outcome + +- **GIVEN** a task already completed with an approving outcome, and a + notification whose reject action was rendered before that +- **WHEN** the reject action is invoked +- **THEN** it MUST be refused with a conflict naming the terminal state +- **AND** the recorded outcome MUST still be the approving one +- @e2e exclude covered by notification-action conflict unit tests + +### Requirement: Deadline notifications filter on the derived predicate + +Any rule that notifies about a task being due, nearly due, or overdue SHALL +evaluate a predicate DERIVED from the task's deadline columns against the +current time. No rule SHALL filter on a stored field that records whether a +task is overdue, because `flow-tasks` forbids such a field from existing. + +The derivation backing a deadline notification SHALL be the same derivation +that backs the inbox filter and the API projection. Two implementations of +"is this overdue" SHALL NOT exist. + +The measured counter-example is decidesk's `actionOverdue` +(`decidesk/lib/Settings/decidesk_register.json:4173-4180`), whose filter is +`taskStatus: "overdue"` — a clock-derived value written by hand, so the +notification reaches only the tasks something remembered to stamp. + +#### Scenario: A task becomes notifiable without anything writing to it + +- **GIVEN** a task whose deadline is in the future and on which no write has + occurred +- **WHEN** the clock passes the deadline and the scheduled evaluation runs +- **THEN** the overdue notification MUST be produced +- **AND** the task's stored row MUST be unchanged by the evaluation +- @e2e exclude covered by clock-controlled scheduled-rule unit tests + +#### Scenario: A terminal task is not chased + +- **GIVEN** a task past its deadline that has already reached a terminal + state +- **WHEN** the scheduled evaluation runs +- **THEN** no overdue notification MUST be produced for it +- @e2e exclude covered by clock-controlled scheduled-rule unit tests + +### Requirement: An assigned task appears in the assignee's own calendar + +When a task has a resolved individual assignee, the system SHALL project it +as a VTODO into that person's calendar. + +The projected VTODO SHALL carry: a summary derived from the task's display +title; a due date derived from the task's advisory deadline where one is +set; the task's priority mapped onto the VTODO priority range; a status +mapped from the task's lifecycle state through one published mapping; the +TASK's identity as a property, so any later write is addressable back to the +task; and a `URL` property that deep-links to the task's own form. + +The deep link SHALL address a surface a person can act on. A link to an API +endpoint SHALL NOT satisfy this requirement. + +A task with no resolved individual assignee — an unclaimed task in a +candidate pool — SHALL NOT be projected into any calendar. A VTODO lives in +exactly one person's calendar and there is no such person yet; projecting it +into an arbitrary member's calendar would assert an assignment that the +engine has not made. + +When a task's assignee changes, the projection SHALL be removed from the +previous assignee's calendar and created in the new assignee's calendar. +When a task reaches a terminal state, the projection SHALL be updated to +reflect it and SHALL NOT remain as outstanding work. + +#### Scenario: A task lands in the assignee's calendar with a working link + +- **GIVEN** a task assigned to a user, with a due date and a subject object +- **WHEN** the projection runs +- **THEN** a VTODO MUST exist in that user's calendar carrying the task's + title, its due date and its priority +- **AND** the VTODO MUST carry a `URL` resolving to the task's form +- **AND** opening that URL MUST present the task +- @e2e an assigned task appears in the assignee's calendar and links back + +#### Scenario: An unclaimed pooled task is in nobody's calendar + +- **GIVEN** a task offered to a candidate group with no assignee +- **WHEN** the projection runs +- **THEN** no VTODO MUST be created in any group member's calendar +- **AND** the task MUST still be listed in every member's inbox +- @e2e exclude covered by projection unit tests + +#### Scenario: Reassignment moves the calendar entry + +- **GIVEN** a task projected into one user's calendar +- **WHEN** it is reassigned to another user +- **THEN** the first user's calendar MUST no longer contain the projection +- **AND** the second user's calendar MUST contain it +- @e2e exclude covered by projection unit tests + +### Requirement: The projection carries a real assignee, not prose + +The projected VTODO SHALL carry the assignee as a machine-readable identity +resolvable to a Nextcloud account. + +The system SHALL NOT encode the assignee as text inside the description, and +SHALL NOT recover an assignee by parsing description text. Any surface that +writes or reads the assignee SHALL use the identity, not the prose. + +This ends a measured defect: `lib/Service/TaskService.php:258-264` recovers +an assignee by taking `substr($description, strlen('Assigned to: '))`, and +`nextcloud-vue/src/components/CnObjectSidebar/CnTasksTab.vue:304` writes +that prefix followed by the user's DISPLAY NAME — so the value does not +round-trip to an account, two users sharing a display name are +indistinguishable, and any user editing the description silently changes who +the task appears to belong to. + +#### Scenario: Two users with the same display name stay distinct + +- **GIVEN** two accounts with identical display names, one of which is + assigned a task +- **WHEN** the projection is read back and its assignee resolved +- **THEN** it MUST resolve to the assigned account +- **AND** MUST NOT resolve to the other account +- @e2e exclude covered by projection assignee unit tests + +#### Scenario: Editing the description does not reassign anything + +- **GIVEN** a projected VTODO whose description is edited to read + "Assigned to: somebody else" +- **WHEN** the write is processed +- **THEN** the task's assignee MUST be unchanged +- @e2e exclude covered by write-back gate unit tests + +### Requirement: Ticking off the VTODO completes the engine task, through authorization + +When a projected VTODO is marked completed in a calendar or task client, the +system SHALL request the corresponding lifecycle verb on the engine task, +identified by the task identity the projection carries. + +Everything arriving this way SHALL be treated as UNTRUSTED INPUT. The engine +SHALL re-validate it in full: that the identity names a real task, that the +task is not already terminal, that the requested transition is legal from +its current state, and that the acting user is authorized to perform it. + +The verb SHALL be invoked through the same service and the same +authorization as any other caller. A write-back path SHALL NOT have a +completion route of its own, and SHALL NOT be able to set a state the +lifecycle would otherwise refuse. + +A write to a VTODO carrying no task identity SHALL be ignored by this +capability entirely — it is an ordinary calendar task and not the engine's +business. + +#### Scenario: Completing in the Tasks app completes the task + +- **GIVEN** a task assigned to a user and projected into their calendar +- **WHEN** the user marks the VTODO completed in a task client +- **THEN** the engine task MUST reach its completed state +- **AND** the task audit MUST record the completion with that user as actor +- @e2e completing the projected VTODO completes the engine task + +#### Scenario: A hand-made calendar task is not touched + +- **GIVEN** a VTODO in the user's calendar carrying no task identity +- **WHEN** it is created, edited and completed +- **THEN** no engine task MUST be created, changed or completed +- @e2e exclude covered by write-back gate unit tests + +#### Scenario: An illegal transition through the calendar is refused + +- **GIVEN** a projected VTODO for a task already in a terminal state +- **WHEN** it is edited back to an incomplete status +- **THEN** the engine task MUST remain terminal +- @e2e exclude covered by write-back gate unit tests + +### Requirement: An unauthorized write-back is undone and explained + +When a write-back is refused — by authorization, by validation, or because +the transition is illegal — the system SHALL NOT leave the projection +showing a state the engine did not accept. + +Where the refusal can be delivered in-band to the client performing the +write, the write SHALL be refused so the client never records it. Where the +write has already been committed, the projection SHALL be REVERTED to the +engine's state. + +In both cases the acting user SHALL be notified, and the notification SHALL +say which task was affected and why the change did not take. A silent revert +is forbidden: the user believes they completed something, and a calendar +entry that quietly reappears reads as a bug rather than as a refusal. + +Every refused write-back SHALL be recorded in the task audit as a denial +with the acting identity and the reason, exactly as a refused API call is. + +#### Scenario: A stranger's tick is undone and the stranger told + +- **GIVEN** a projected VTODO reachable by a user who is not authorized to + complete the underlying task, for example through a shared calendar +- **WHEN** that user marks it completed +- **THEN** the engine task MUST NOT be completed +- **AND** the VTODO MUST end up showing the engine's state, not the user's + edit +- **AND** that user MUST receive a notification naming the task and the + reason +- @e2e an unauthorized calendar completion is reverted and reported + +#### Scenario: A refused write-back is auditable + +- **GIVEN** any refused write-back +- **WHEN** the refusal is processed +- **THEN** a task audit entry MUST record the attempt, the acting identity + and the denial reason +- @e2e exclude covered by write-back gate unit tests + +### Requirement: Projection is idempotent and does not feed itself + +Rendering a projection SHALL be idempotent: rendering the same task twice +without an intervening change SHALL leave the projected surfaces in the same +state and SHALL NOT produce duplicate calendar entries or duplicate +notifications. + +A projection write SHALL NOT be observed by the write-back gate as a user +edit. A write-back that succeeds and causes the projection to be re-rendered +SHALL NOT cause a further write-back. + +#### Scenario: Re-running the projection changes nothing + +- **GIVEN** a task whose projections are current +- **WHEN** the projection is rendered again +- **THEN** the calendar MUST contain exactly one VTODO for that task +- **AND** no additional notification MUST be delivered +- @e2e exclude covered by projection idempotency unit tests + +#### Scenario: A completion through the calendar does not echo + +- **GIVEN** a projected VTODO completed by its assignee +- **WHEN** the engine completes the task and re-renders the projection +- **THEN** exactly one completion MUST be recorded in the task audit +- @e2e exclude covered by write-back loop unit tests + +### Requirement: A delivery failure never fails the task + +A failure to deliver any projection — an unreachable calendar backend, a +user with no calendar that accepts tasks, a notification backend error — +SHALL NOT fail, roll back, or block the lifecycle transition that caused it. + +The transition SHALL commit, the audit SHALL be written, and the projection +SHALL be retried or reconciled afterwards. The failure SHALL be logged +naming the task and the surface that failed. + +Delivery is how a person finds out about a task. It is not a condition of +the task existing, and making it one would let a calendar outage stop an +approval chain. + +#### Scenario: A calendar outage does not stop an approval + +- **GIVEN** a calendar backend that fails every write +- **WHEN** a task is assigned +- **THEN** the assignment MUST succeed and MUST be recorded in the audit +- **AND** the task MUST appear in the assignee's inbox +- **AND** the calendar failure MUST be logged naming the task +- @e2e exclude covered by projection failure-isolation unit tests + +#### Scenario: A user with no task-capable calendar still gets tasks + +- **GIVEN** an assignee whose account has no calendar accepting tasks +- **WHEN** a task is assigned to them +- **THEN** the assignment MUST succeed +- **AND** the task MUST be notified and listed in their inbox +- @e2e exclude covered by projection failure-isolation unit tests + +### Requirement: The inbox surfaces read the inbox, and count from its total + +The system SHALL provide a dashboard widget listing the calling user's open +tasks and a per-object surface listing the tasks anchored to an object. Both +SHALL read the task inbox query owned by `flow-tasks`. + +Filtering, sorting and pagination SHALL be performed by that query. A +surface SHALL NOT apply a filter of its own over a page of results, because +a client-side filter over server-paginated data silently drops the rows the +current page did not contain and reports a count that is wrong. + +Any badge or count SHALL be taken from the total the query reports, never +from the number of rows the surface happens to be holding. + +Each listed row SHALL carry enough subject context to be readable without a +further request per row. + +#### Scenario: A badge counts the work, not the page + +- **GIVEN** a user with 120 open tasks and a widget page size of 25 +- **WHEN** the widget renders +- **THEN** it MUST display 25 rows +- **AND** its count MUST read 120 +- @e2e the task widget shows a page of rows and the full count + +#### Scenario: A filter narrows the query, not the page + +- **GIVEN** a user with tasks spanning several states, filtered to one state +- **WHEN** the filter is applied +- **THEN** the request MUST carry the filter to the inbox query +- **AND** the reported total MUST be the total matching the filter across all + pages +- @e2e exclude covered by widget unit tests asserting the outgoing request + +### Requirement: The task surfaces speak the task vocabulary + +Every user-facing task surface SHALL express the lifecycle vocabulary that +`flow-tasks` defines, and SHALL NOT be limited to the four-value VTODO +status vocabulary it used previously. + +A surface SHALL be able to display: a task in any of the lifecycle states; a +pooled task with no assignee, offered rather than assigned; a task shown as +overdue from the derived predicate rather than from a stored field; and the +lifecycle verbs the calling user is authorized to invoke, and only those. + +A surface SHALL NOT offer a verb the calling user is not authorized to +perform. Offering it and letting the server refuse teaches users that the +interface is unreliable, and it leaks who else can act. + +#### Scenario: A pooled task is shown as claimable, not as assigned + +- **GIVEN** an unclaimed task in a candidate group the calling user belongs + to +- **WHEN** it is listed +- **THEN** it MUST be shown with no assignee +- **AND** a claim action MUST be offered +- @e2e exclude covered by task surface unit tests + +#### Scenario: A verb the user cannot perform is not offered + +- **GIVEN** a task visible to a watcher who has no lifecycle rights on it +- **WHEN** the watcher views it +- **THEN** no lifecycle verb MUST be offered +- **AND** the task MUST still be readable +- @e2e a watcher sees the task and no action buttons diff --git a/openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md b/openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md new file mode 100644 index 0000000000..6b4527f080 --- /dev/null +++ b/openspec/changes/flow-task-inbox-projections/specs/object-interactions/spec.md @@ -0,0 +1,288 @@ +## MODIFIED Requirements + +### Requirement: Tasks on Objects via CalDAV VTODO + +The system SHALL provide a `TaskService` that creates, reads, updates, and deletes CalDAV VTODO items linked to OpenRegister objects. Each VTODO MUST include `X-OPENREGISTER-REGISTER`, `X-OPENREGISTER-SCHEMA`, and `X-OPENREGISTER-OBJECT` custom properties, plus an RFC 9253 LINK property pointing back to the object API endpoint. Tasks MUST be stored in a VTODO-supporting calendar via `OCA\DAV\CalDAV\CalDavBackend`. + +VTODOs SHALL fall into two classes, distinguished by whether they carry an +ENGINE TASK identity property: + +- **Standalone VTODOs** carry no engine task identity. They are user-created + tasks on an object, the VTODO is their store, and every behaviour in this + requirement applies to them unchanged. +- **Projected VTODOs** carry an engine task identity. For these the VTODO is + NOT the store: the engine task record is, and `TaskService` acts as the + PROJECTION WRITER. Their lifecycle, assignee and outcome are governed by + `flow-task-projections`, not by this capability. + +`TaskService` SHALL refuse to create a projected VTODO through the +object sub-resource endpoints. A projected VTODO is created only by the +projection, so that a VTODO carrying an engine task identity always +corresponds to a task the engine authorized into existence. + +Deleting a projected VTODO through this capability's endpoints SHALL NOT +delete the engine task. The projection SHALL be restored on the next +reconciliation, because a task is not cancelled by removing the reminder of +it. + +The assignee of a task SHALL NOT be encoded in, or recovered from, the +VTODO `DESCRIPTION`. Any assignee carried on a VTODO SHALL be a +machine-readable identity resolvable to a Nextcloud account. + +#### Scenario: Create a task linked to an object +- **GIVEN** an OpenRegister object with UUID `abc-123` in register 5, schema 12 +- **WHEN** a POST request is sent to `/api/objects/5/12/abc-123/tasks` with body `{"summary": "Review documents", "due": "2026-03-01T17:00:00Z", "priority": 1}` +- **THEN** a VTODO MUST be created in a VTODO-supporting calendar with: + - `X-OPENREGISTER-REGISTER:5` + - `X-OPENREGISTER-SCHEMA:12` + - `X-OPENREGISTER-OBJECT:abc-123` + - `LINK;LINKREL="related";VALUE=URI:/apps/openregister/api/objects/5/12/abc-123` + - `STATUS:NEEDS-ACTION`, `PRIORITY:1`, `SUMMARY:Review documents`, `DUE:20260301T170000Z` +- **AND** the VTODO MUST carry no engine task identity property +- **AND** the response MUST return HTTP 201 with the task as JSON including `id`, `uid`, `calendarId`, `summary`, `description`, `status`, `priority`, `due`, `completed`, `created`, `objectUuid`, `registerId`, `schemaId` +- @e2e exclude covered by TaskService creation unit tests + +#### Scenario: List tasks for an object +- **GIVEN** 3 VTODOs exist with `X-OPENREGISTER-OBJECT:abc-123` +- **WHEN** a GET request is sent to `/api/objects/5/12/abc-123/tasks` +- **THEN** the response MUST return `{"results": [...], "total": 3}` with all 3 tasks +- **AND** each task MUST include: `id` (URI), `uid`, `calendarId`, `summary`, `description`, `status`, `priority`, `due`, `completed`, `created`, `objectUuid`, `registerId`, `schemaId` +- @e2e exclude covered by TaskService listing unit tests + +#### Scenario: Update task status to completed +- **GIVEN** a standalone VTODO linked to object `abc-123` with status `NEEDS-ACTION` +- **WHEN** a PUT request is sent with `{"status": "completed"}` +- **THEN** the VTODO STATUS MUST be set to `COMPLETED` +- **AND** the `COMPLETED` timestamp MUST be set to the current UTC time +- **AND** the `X-OPENREGISTER-*` properties MUST remain unchanged +- **AND** the response MUST return the updated task as JSON +- @e2e exclude covered by TaskService update unit tests + +#### Scenario: Delete a task +- **GIVEN** a standalone VTODO linked to object `abc-123` +- **WHEN** a DELETE request is sent to `/api/objects/5/12/abc-123/tasks/{taskId}` +- **THEN** the VTODO MUST be removed from the calendar via `CalDavBackend::deleteCalendarObject()` +- **AND** the response MUST return `{"success": true}` +- @e2e exclude covered by TaskService deletion unit tests + +#### Scenario: Task summary is required +- **GIVEN** a POST request to create a task with empty summary +- **WHEN** the controller validates the request +- **THEN** the API MUST return HTTP 400 with `{"error": "Task summary is required"}` +- @e2e exclude covered by TasksController validation unit tests + +#### Scenario: An engine task cannot be forged through this endpoint +- **GIVEN** a POST request to `/api/objects/5/12/abc-123/tasks` whose payload attempts to set an engine task identity +- **WHEN** the request is processed +- **THEN** it MUST be refused +- **AND** no VTODO carrying an engine task identity MUST be created +- @e2e exclude covered by TasksController validation unit tests + +#### Scenario: Deleting a projected VTODO does not cancel the task +- **GIVEN** a projected VTODO for an engine task in a non-terminal state +- **WHEN** it is deleted through this capability's endpoint +- **THEN** the engine task MUST remain in its current state +- **AND** the projection MUST be restored on the next reconciliation +- @e2e exclude covered by projection-reconciliation unit tests + +### Requirement: Task Status Mapping + +The system SHALL map CalDAV VTODO STATUS values to lowercase JSON strings for consistent API responses. The mapping MUST be bidirectional: incoming status values from the API MUST be converted to uppercase for CalDAV storage. + +For a PROJECTED VTODO the wire mapping above still applies, but the VTODO +status is not the task's state. The system SHALL publish one mapping between +the engine task's lifecycle states and the four VTODO status values, and +SHALL apply that mapping in both directions: rendering the projection, and +interpreting a write-back. + +The engine lifecycle has more states than the VTODO vocabulary can express, +so the mapping SHALL be lossy in the render direction and SHALL NOT be lossy +in the interpret direction: a VTODO status SHALL map to a REQUESTED +TRANSITION rather than to a state, and a status that names no legal +transition from the task's current state SHALL be refused rather than +applied. + +#### Scenario: Status normalization on read +- **GIVEN** a VTODO with `STATUS:NEEDS-ACTION` +- **WHEN** the task is returned via the API +- **THEN** the `status` field MUST be `"needs-action"` +- @e2e exclude covered by status-mapping unit tests + +#### Scenario: Status normalization on write +- **GIVEN** an API request with `{"status": "in-process"}` +- **WHEN** the task is updated +- **THEN** the VTODO STATUS MUST be set to `IN-PROCESS` +- @e2e exclude covered by status-mapping unit tests + +#### Scenario: Complete status mapping table +- **GIVEN** the following CalDAV STATUS values +- **THEN** the mapping MUST be: + - `NEEDS-ACTION` to/from `"needs-action"` + - `IN-PROCESS` to/from `"in-process"` + - `COMPLETED` to/from `"completed"` + - `CANCELLED` to/from `"cancelled"` +- @e2e exclude covered by status-mapping unit tests + +#### Scenario: A VTODO status maps to a transition, not to a state +- **GIVEN** a projected VTODO whose engine task is in a state from which the requested transition is not legal +- **WHEN** its status is changed in a calendar client +- **THEN** the change MUST be refused +- **AND** the engine task's state MUST NOT be set directly from the VTODO status +- @e2e exclude covered by write-back gate unit tests + +### Requirement: Calendar Selection for Tasks + +The system SHALL determine which CalDAV calendar to use by finding a calendar that supports VTODO components, checking the `supported-calendar-component-set` property on each calendar and handling object, string, and iterable component sets. + +The OWNER of the calendar SHALL depend on the class of task: + +- For a **standalone** task created through the object sub-resource + endpoints, the calendar SHALL be the SESSION USER's first VTODO-supporting + calendar. +- For a **projected** task, the calendar SHALL be the ASSIGNEE's first + VTODO-supporting calendar. A projection SHALL NOT be written into the + calendar of whoever happened to trigger the transition — the assignee owes + the work, and it is their calendar the reminder belongs in. + +A task with no resolved individual assignee SHALL NOT be projected into any +calendar. + +Absence of a VTODO-supporting calendar SHALL be an error for a standalone +task and SHALL NOT be an error for a projection: the projection SHALL be +skipped and logged, and the task SHALL remain fully usable through every +other surface. + +#### Scenario: Use first VTODO-supporting calendar +- **GIVEN** the user has calendars `personal` (VEVENT+VTODO) and `birthdays` (VEVENT only) +- **WHEN** a standalone task is created or listed +- **THEN** the service MUST use the `personal` calendar +- @e2e exclude covered by calendar-selection unit tests + +#### Scenario: No VTODO-supporting calendar available +- **GIVEN** the user has no calendars that support VTODO +- **WHEN** a standalone task operation is attempted +- **THEN** the service MUST throw an Exception with message `"No VTODO-supporting calendar found for user {uid}"` +- **AND** the controller MUST return HTTP 500 +- @e2e exclude covered by calendar-selection unit tests + +#### Scenario: No user logged in +- **GIVEN** no user session is active +- **WHEN** a standalone task operation is attempted +- **THEN** the service MUST throw an Exception with message `"No user logged in"` +- @e2e exclude covered by calendar-selection unit tests + +#### Scenario: A projection targets the assignee's calendar +- **GIVEN** an engine task assigned to a user, transitioned by a different user +- **WHEN** the projection runs +- **THEN** the VTODO MUST be written to the ASSIGNEE's calendar +- **AND** the transitioning user's calendar MUST NOT receive it +- @e2e exclude covered by projection unit tests + +#### Scenario: A missing calendar skips the projection, not the task +- **GIVEN** an assignee with no VTODO-supporting calendar +- **WHEN** a task is assigned to them +- **THEN** the assignment MUST succeed +- **AND** the skipped projection MUST be logged naming the task +- @e2e exclude covered by projection failure-isolation unit tests + +### Requirement: Task Compatibility with Nextcloud Tasks App + +Tasks created through OpenRegister MUST be fully compatible with Nextcloud's Tasks app. The `X-OPENREGISTER-*` custom properties MUST NOT break standard CalDAV clients, which ignore unknown X- properties per RFC 5545. Users MUST be able to view OpenRegister-linked tasks in the Nextcloud Tasks app. + +For a **standalone** VTODO, editing it in the Tasks app SHALL change the +task, because the VTODO is the store. + +For a **projected** VTODO, editing it in the Tasks app SHALL be treated as a +REQUEST against the engine task. Completing it SHALL request the +corresponding lifecycle verb; any edit that names no lifecycle verb SHALL +leave the engine task unchanged and SHALL be overwritten by the next +projection render. An edit that is refused SHALL NOT be left standing in the +calendar. + +A projected VTODO SHALL carry a `URL` property deep-linking to the engine +task's own form, so that a person who opens it in a task client can reach +the surface that can actually answer it. + +#### Scenario: Task visible in Nextcloud Tasks app +- **GIVEN** a task created via OpenRegister's API on object `abc-123` +- **WHEN** the user opens the Nextcloud Tasks app +- **THEN** the task MUST appear in the user's calendar with its summary, due date, priority, and status +- @e2e exclude covered by CalDAV round-trip unit tests + +#### Scenario: Task edited in Nextcloud Tasks app +- **GIVEN** a STANDALONE task linked to object `abc-123` is edited in the Nextcloud Tasks app (e.g., status changed to completed) +- **WHEN** the task is queried via OpenRegister's API +- **THEN** the updated status MUST be reflected in the API response +- **AND** the `X-OPENREGISTER-*` linking properties MUST remain intact +- @e2e exclude covered by CalDAV round-trip unit tests + +#### Scenario: X-properties ignored by third-party CalDAV clients +- **GIVEN** a third-party CalDAV client syncs the user's calendar +- **WHEN** it encounters `X-OPENREGISTER-REGISTER`, `X-OPENREGISTER-SCHEMA`, `X-OPENREGISTER-OBJECT` +- **THEN** the client MUST ignore these properties per RFC 5545 section 3.8.8.2 (non-standard properties) +- @e2e exclude covered by CalDAV round-trip unit tests + +#### Scenario: Projected task edited in Nextcloud Tasks app +- **GIVEN** a projected VTODO for an engine task assigned to the acting user +- **WHEN** the user marks it completed in the Tasks app +- **THEN** the engine task MUST be completed through its authorized lifecycle verb +- **AND** the projection MUST be re-rendered to match the resulting state +- @e2e completing the projected VTODO completes the engine task + +#### Scenario: A projected task links to its form +- **GIVEN** a projected VTODO opened in a task client +- **WHEN** its `URL` property is followed +- **THEN** it MUST resolve to the engine task's form +- @e2e an assigned task appears in the assignee's calendar and links back + +### Requirement: User-Wide Task Aggregate Endpoint + +The system MUST expose a user-wide task aggregate endpoint that returns the +tasks the current session user owes, independent of any single object. + +The aggregate SHALL be answered from the engine task inbox, not by walking +the user's calendars. It MUST resolve the caller from the session +(`IUserSession`), never from a request parameter, so the endpoint cannot be +used to read another user's tasks. Visibility MUST be enforced in the query: +the response MUST contain only tasks the caller may see, and the reported +total MUST NOT reveal the existence of tasks it excludes. + +Filtering, sorting, pagination and the total MUST be performed by the query. +Filtering the aggregate in the application layer after retrieval is +forbidden: it silently drops rows outside the retrieved page and reports a +total that does not match the filter. + +It MUST accept optional state, derived-overdue and pagination +(`_limit`/`limit` capped at 200, `_offset`/`offset`) filters. `assignee` +SHALL NO LONGER be accepted as a free-text filter over description prose; a +task's assignee is a resolved identity, and the aggregate is already scoped +to the caller. On error it MUST return HTTP 500 with an `error` message. + +#### Scenario: List all of the current user's tasks +- **GIVEN** an authenticated user with engine tasks assigned to them and pooled to their groups +- **WHEN** a GET request is sent to `/api/tasks` +- **THEN** the response MUST list those tasks, resolved from `IUserSession::getUser()->getUID()` +- **AND** the response MUST NOT depend on any object register/schema/id +- **AND** no CalDAV calendar MUST be enumerated to produce it +- @e2e the user-wide task aggregate lists engine tasks + +#### Scenario: Filter the aggregate by status and assignee +- **GIVEN** a GET request to `/api/tasks?status=active&assignee=jan` +- **WHEN** the controller reads the parameters +- **THEN** the lifecycle state filter MUST be applied by the inbox query +- **AND** the reported total MUST be the total matching the filter, not the number of rows returned +- **AND** `assignee` MUST NOT be honoured as a filter: the aggregate is already scoped to the caller, and a free-text assignee filter over description prose no longer exists +- @e2e exclude covered by TasksController unit tests + +#### Scenario: Aggregate pagination caps the limit +- **GIVEN** a GET request to `/api/tasks?_limit=500` +- **WHEN** the controller computes the limit +- **THEN** the effective limit MUST be capped at 200 +- @e2e exclude covered by TasksController unit tests + +#### Scenario: The aggregate does not leak another user's tasks +- **GIVEN** a GET request to `/api/tasks` carrying a parameter naming another user +- **WHEN** the request is processed +- **THEN** the response MUST contain only the session user's visible tasks +- **AND** the reported total MUST NOT include tasks the caller may not see +- @e2e exclude covered by TasksController authorization unit tests diff --git a/openspec/changes/flow-task-inbox-projections/tasks.md b/openspec/changes/flow-task-inbox-projections/tasks.md new file mode 100644 index 0000000000..9d030ba17d --- /dev/null +++ b/openspec/changes/flow-task-inbox-projections/tasks.md @@ -0,0 +1,206 @@ +# Tasks: flow-task-inbox-projections + +## 1. Dialect: a notification that can decide + +- [ ] 1.1 Add the `task-verb` action target kind to + `lib/Service/Notification/NotificationAnnotationValidator.php` beside + the three at `:60` (`object-detail|route|url`): it names a lifecycle + verb and an optional outcome, never an author-composed URL. Keep + `MAX_ACTIONS = 2` (`:68`) — approve and reject is exactly two. Reject + an unknown verb naming the value. Existing rules keep validating + unchanged. +- [ ] 1.2 Render it as a state-changing action: + `lib/Notification/AnnotationNotifier.php:235` hardcodes + `->setLink($url, 'GET')` for every declared action; a `task-verb` + target renders POST against the `TaskController` verb route, and the + other three kinds keep rendering GET. Resolve the target URL in the + dispatcher's target resolver (`AnnotationNotificationDispatcher.php:2598-2640` + handles `url`/`route`/`object-detail` today). A verb whose outcome + requires a mandatory comment resolves to the task form instead — a + GET — because `flow-tasks` refuses a rejecting outcome without one. + +## 2. Task notifications, declaratively + +- [ ] 2.1 `lib/Service/Notification/TaskObjectAdapter.php` extending + `ObjectEntity`, modelled on `SystemEntityObjectAdapter.php:46`. Entity + uuid = TASK uuid (so `NotificationDedupeState`, which is keyed per + object, dedupes per task). Flattens assignee, candidate users/groups, + requester, watchers, priority, `due_at`, `is_terminal`, the derived + overdue fields and the subject object's context into payload fields + recipients and filters can read. NO notification logic in it — design + D-1's fence. +- [ ] 2.2 `lib/Service/Notification/TaskNotificationRules.php` modelled on + `SystemSchemaRules.php:58`: the rule set from design.md — Seed Data, + addressed at `trigger.action` (matched at + `AnnotationNotificationDispatcher.php:1523-1539`), covering offered, + assigned, reassigned-away, due-soon, escalated and cancelled. The + overdue rule uses the operator-object filter grammar verified at + `shillinq/lib/Settings/register.d/contract-lifecycle-management.json:383-401` + over DERIVED fields — no rule anywhere filters a stored `overdue`. +- [ ] 2.3 `lib/Listener/TaskNotificationListener.php` on the task lifecycle + events, calling + `AnnotationNotificationDispatcher::dispatchWithSchema()` (`:211`) with + `context['action']` set to the recorded transition action — the same + seam `SystemEntityNotificationListener.php:94-127` uses. No second + notification pipeline. +- [ ] 2.4 Withdrawal via `IManager::markProcessed()` (the call already used + at `lib/Service/NotificationService.php:228`) on every terminal + transition and on assignee change: a claimed pool task clears the + other members' notifications; a task terminated by propagation leaves + no approve button standing. + +## 3. The calendar projection + +- [ ] 3.1 Projection state per task (rendered-content hash + timestamp, + written by the projector only, read by no lifecycle or authorization + rule) so idempotency, echo suppression and drift detection are + comparisons rather than guesses — design D-2 rules 6 and 8. +- [ ] 3.2 `lib/Service/Task/TaskCalendarProjector.php` rendering the VTODO + from the task per the property table in design.md — D-5, including the + new `URL` (a form deep link; `lib/Service/TaskService.php:400-440` + emits no `URL` at all today), `X-OPENREGISTER-TASK` and + `X-OPENREGISTER-TASK-ASSIGNEE`. Into the ASSIGNEE's calendar: + `findUserCalendar()` (`:561-582`) resolves the SESSION user today and + must be parameterised by uid. A pooled task with no assignee is NOT + projected. Reassignment removes and recreates; a terminal task is + rendered terminal. +- [ ] 3.3 Invert `lib/Service/TaskService.php` (753L) from store to + projection writer per the method table in design.md — D-7: two classes + keyed on `X-OPENREGISTER-TASK`; `createTask()` refuses an + engine-identity payload from the sub-resource endpoint; + `deleteTask()` on a projected VTODO does not cancel the task. Delete + `extractAssigneeFromDescription()` (`:258-264`) and the + `'Assigned to: '` writer at + `nextcloud-vue/src/components/CnObjectSidebar/CnTasksTab.vue:304`, and + fix the docblock at `:112` that claims ATTENDEE matching no code + performs. +- [ ] 3.4 Run projection and notification AFTER the lifecycle transaction + commits, never inside it (design D-8): a calendar outage or an + assignee with no VTODO-capable calendar logs and skips naming the + task, and the transition still succeeds. `findUserCalendar()`'s + `NoVtodoCalendarException` (`:576`) stays fatal for a STANDALONE task + and becomes a skip for a projection. Reconciliation re-renders what + was skipped. + +## 4. The write-back gate + +- [ ] 4.1 One gate implementation taking `(task_uuid, requested_verb, + actor)` and nothing else — never a state, never a field value (design + D-2 rule 3). It calls the entity `TaskService`, which authorizes. + Fail-closed on unresolvable task, illegal transition, unknown property + shape or unavailable authorization. Only `STATUS` and deletion name + verbs; `SUMMARY`/`DESCRIPTION`/`DUE`/`PRIORITY` edits reach the engine + never and are overwritten by the next render. +- [ ] 4.2 `lib/Dav/TaskVtodoWriteBackPlugin.php` — a Sabre `ServerPlugin` + declared in `appinfo/info.xml` under `` (the + mechanism `apps/dav/lib/AppInfo/PluginManager.php:155-160` loads) on + `beforeWriteContent`/`beforeUnbind`, acting only on VTODOs carrying + `X-OPENREGISTER-TASK`. A refusal throws a DAV forbidden exception so + the client never records the change. +- [ ] 4.3 `lib/Listener/TaskVtodoWriteBackListener.php` on the `apps/dav` + `CalendarObjectUpdatedEvent`/`CalendarObjectDeletedEvent` for writes + that bypass the plugin: here the write has committed, so REVERT the + projection to the engine's truth and notify the actor naming the task + and the reason. No silent revert anywhere. Every refusal writes a task + audit denial with actor and reason. + +## 5. Inbox surfaces + +- [ ] 5.1 `CnTasksWidget` in nextcloud-vue modelled on + `src/components/CnFlowRunsWidget/CnFlowRunsWidget.vue` (559L), reading + the `flow-tasks` inbox API. Filter, sort, page and total come from the + query; the badge reads the TOTAL, never the row count; no client-side + filter is applied over a returned page. +- [ ] 5.2 Repoint the leaf: `src/integrations/builtin/tasks.js` (64L), + `CnObjectSidebar/CnTasksTab.vue` (500L), + `CnTasksCard/CnTasksCard.vue` (387L) and `src/types/task.d.ts` (31L) + off the VTODO sub-resource endpoints (`appinfo/routes.php:906-909`) + onto tasks-by-object. `TTaskStatus` widens from the four VTODO values + (`task.d.ts:5`) to the six CMMN states; `isOverdue` becomes + server-derived; the assignee becomes an identity; a pooled task shows + no assignee and offers claim; and only verbs the caller is authorized + to invoke are offered. Coordinate with decidesk, the only mounter + (`decidesk/src/manifest.json:594`). +- [ ] 5.3 `GET /api/tasks` (`appinfo/routes.php:903`) answers from the inbox + instead of `TaskService::getAllUserTasks()` (`:120-197`, which walks + every calendar and filters and paginates in PHP). Visibility, filter, + sort, page and total are the query's; `assignee` is no longer accepted + as a free-text filter; the 200 limit cap stays. + +## 6. Seed data and tests + +- [ ] 6.1 Install the rule set and projection fixtures from design.md — + Seed Data (assigned-to-you with two `task-verb` actions; offered-to- + pool via `kind: groups` from the task's own candidate groups; overdue + in the verified filter grammar; cancelled-by-propagation; one + projected task, one pooled task with no projection, one assignee with + no VTODO-capable calendar) through the existing seeding path, + idempotent. +- [ ] 6.2 Gate tests: a stranger's completion refused and audited; a shared- + calendar tick reverted and the actor notified; an illegal transition + from a terminal task refused; a VTODO with no `X-OPENREGISTER-TASK` + untouched through create/edit/complete; a `SUMMARY` edit not reaching + the engine; both hooks proven to reach the one gate. +- [ ] 6.3 Contract tests: rendering twice produces one VTODO and no second + notification; a gate-driven completion records exactly one audit + entry (no echo); a deleted VTODO is rebuilt with identical content and + the task untouched; a clock-controlled overdue rule fires with the row + byte-identical before and after; a failing calendar backend leaves the + assignment committed and inboxed. +- [ ] 6.4 Playwright coverage for the six `@e2e`-marked scenarios across + `specs/flow-task-projections/spec.md` and + `specs/object-interactions/spec.md`: approve from the notification; + the assigned task in the calendar with a link that resolves to the + form; completing the projected VTODO; the unauthorized calendar + completion reverted and reported; the widget's page-of-rows with the + full count; the watcher who sees a task and no action buttons; and the + aggregate listing engine tasks. + +## Acceptance criteria + +- No code path in this change calls `INotificationManager` to send a task + notification. Every task notification originates in an + `x-openregister-notifications` rule evaluated by + `AnnotationNotificationDispatcher`. +- No rule, filter, column or payload field anywhere records whether a task + is overdue. The scheduled rule filters the same derivation the inbox + filter and the API projection use. +- The only path from a projection into the engine carries + `(task_uuid, requested_verb, actor)`. Grep the write-back gate: no code + assigns a lifecycle state, an assignee or an outcome from VTODO content. +- Every refused write-back leaves the engine unchanged, the projection + showing the engine's state, an audit denial recorded, and the actor + notified. There is no silent-revert branch. +- A pooled task with no assignee exists in no calendar, and is still listed + and notified. +- No lifecycle transition can be failed or rolled back by a projection or + notification failure. A test with a calendar backend that fails every + write proves it. +- The word `'Assigned to: '` appears nowhere in OpenRegister or + nextcloud-vue, and no code reads an assignee out of a description. +- A VTODO carrying no `X-OPENREGISTER-TASK` behaves exactly as it did before + this change, through create, list, edit, complete and delete. +- Nothing in this change defines a lifecycle rule, an authorization rule, a + flow node, a task form, an SLA computation, or migrates a fleet task + shape. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan); nextcloud-vue + lint and vitest pass. +- Every new PHP file carries `@license EUPL-1.2` and + `@copyright 2026 Conduction B.V.`; every public/protected method carries a + `@spec openspec/specs/flow-task-projections/spec.md` anchor, and the + changed `object-interactions` methods point at that spec. +- The `hydra-gate-notification-dialect` gate passes: no legacy dialect, and + no imperative object-notification dispatch introduced in a leaf. +- Regression check with decidesk installed — the only app mounting the tasks + leaf (`decidesk/src/manifest.json:594`) and the owner of the two rules + being generalised. Its suite is green and its action-item surfaces still + render. +- Depends on `flow-task-entity`: the table, the verbs, the authorization and + the inbox query exist before anything here projects them. +- References ADR-098 D2 (delivery on Nextcloud's own entities as a + projection), ADR-031 (declarative-vs-imperative — design.md D-1), + ADR-002 (CalDAV substrate), ADR-005 (the gate is fail-closed), ADR-001 + (seed data). diff --git a/openspec/changes/flow-user-task-node/.openspec.yaml b/openspec/changes/flow-user-task-node/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/flow-user-task-node/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/flow-user-task-node/design.md b/openspec/changes/flow-user-task-node/design.md new file mode 100644 index 0000000000..93f2fd8321 --- /dev/null +++ b/openspec/changes/flow-user-task-node/design.md @@ -0,0 +1,391 @@ +# Design: flow-user-task-node + +## Context + +See proposal.md — Why for the motivation. What matters for the approach is +what the engine already does, measured: + +- **Suspension is an exception, not a return value.** `FlowSuspension` + (`lib/Service/Flow/FlowSuspension.php:42-58`) is thrown, deliberately, so a + node that forgets cannot silently continue. There is no return path on + which to flush buffered state — which is why `FlowNodeResumeState` writes + straight through to its parent (`FlowNodeResumeState.php:12-15`). +- **Resume state is already per node.** The dispatcher hands each node a + handle scoped to that node (`FlowNodeResumeState.php:39-65`), so two nodes + of the same type in one flow cannot read or overwrite each other's + progress. `AwaitSignalNode` uses exactly this for `askedAt` + (`AwaitSignalNode.php:356-374`). +- **The signal slot is NOT per node.** `$context['signal']` is one key + (`FlowRunService.php:74`), set by `signal()` (`:530`) and unset by the walk + it wakes (`:807`). One question, one answer, one slot. +- **Only two nodes suspend at all.** `WaitNode.php:192` and + `AwaitSignalNode.php:266` are the only `FlowSuspension` throws in `lib/`, + and both pass a non-null time. `SubFlowNode` runs its child inline + (`SubFlowNode.php:327`) rather than suspending the parent. +- **So the abandoned-signal reaper currently reaps nothing.** + `FlowRunMapper::findAbandonedSignals()` requires + `resume_at IS NULL` (`lib/Db/FlowRunMapper.php:589-605`) and no shipped + node produces that. The 14-day failure at `FlowRunWorker.php:311-349` is a + loaded gun aimed at a case that does not yet exist. +- **The engine already has both advance paths.** `FlowRunAdvancer::advance()` + takes `rethrow`, and `FlowService::run()` calls it with `rethrow: true` + when a caller asked to wait (`FlowService.php:511`). The worker calls it + with `rethrow: false` (`FlowRunWorker.php:439`). What is missing is a way + for a NODE to choose which one runs after it. +- **`configForm()` already reaches the builder.** `FlowNodeRegistry::palette()` + publishes `configForm` for any node declaring `IFlowNodeConfigForm` + (`FlowNodeRegistry.php:243-250`), served at + `GET /api/flow/node-catalog` (`FlowController.php:213`). +- **The task itself is somebody else's design.** `flow-task-entity` owns the + table, the six CMMN states, the ten lifecycle verbs, the fail-closed + authorization, the performer model, the five routing strategies and the + append-only audit. This change consumes all of it. + +## Goals / Non-Goals + +**Goals:** + +- One node type that is a thin, honest bridge between the graph and + `TaskService` — validation, templating, suspend/resume, item placement, + and nothing else. +- Resume driven by the TASK's terminality, so the semantics survive two + user-task nodes in one flow without a per-run answer slot. +- A completion-latency control (`advance`) that reuses the engine's existing + synchronous path instead of adding a second execution route. +- Cancellation propagation supplied at the point that knows the mapping — + the node — so `flow-task-entity`'s propagation rules have something to + fire on. + +**Non-Goals:** + +- Re-modelling anything `flow-tasks` specifies. If a rule is about who may + claim, what a state means, or how a routing strategy resolves, it does not + belong in this node. +- In-request advancing as a general engine feature. The budget is a ceiling + on a walk THIS node's completion initiates; no other caller gains it here. +- Replacing `AwaitSignalNode`, and no change to its file at all. +- Any notification, calendar entry, form definition, SLA arithmetic or BPMN + mapping — four other changes own those (proposal.md — What does NOT + change). + +## Decisions + +### D-1 — Declarative-vs-imperative decision (ADR-031) + +**The node is imperative because a node IS the imperative half of the +platform. What it configures stays declarative, and it is fenced from +carrying any business rule.** + +ADR-031's test is: when an `x-openregister-*` schema extension expresses the +requirement, declare it rather than write a service. Applied here: + +**Imperative — the node.** ADR-031's declarative surface is anchored to OR +OBJECTS: `x-openregister-lifecycle` evaluates transitions over object state, +`x-openregister-notifications` fires on object events. A flow node is +addressed by a Petri-net transition and receives ITEMS, not an object; its +whole contract is `execute(items, config, context)` +(`lib/Service/Flow/IFlowNode.php:159`). There is no schema annotation that +can throw `FlowSuspension`, and there should not be — suspension is engine +mechanics, the category ADR-031 explicitly preserves for PHP. `WaitNode`, +`SwitchNode` and `AwaitSignalNode` are all in this category already; this +node joins them rather than inventing a precedent. + +**Declarative — what the node points AT.** The node holds no rule about who +may perform the task; it names candidates and a strategy and lets +`TaskAuthorizationService` decide, which is `flow-task-entity`'s D-1 +argument and not re-made here. It writes no notification: telling the +performer is `x-openregister-notifications` on the task projection +(`flow-task-inbox-projections`), addressing the NAMED TRANSITION ACTIONS +`flow-tasks` records. It computes no deadline: escalation rules are +declarative and belong to `flow-business-timers`. + +**Derived, never stored on the node.** The node does not cache the task's +state in its resume slot. It stores the task UUID and the creation time, and +asks the task service for terminality on every pass. A cached state is a +clock-adjacent fact maintained by hand, and the fleet has already paid for +that once — three schemas store `overdue`, and decidesk's `actionOverdue` +notification fires only when something remembered to write it. + +**The fence.** `UserTaskNode` may not contain a branch about what a specific +app's task MEANS. Every branch in it must be about the graph (do I have +items?), the task's terminality (may I continue?), or placement (where does +the outcome go?). A branch on an approval's business meaning belongs on an +edge condition, where the author can see it. + +### D-2 — Resume on task terminality, not on the run's signal slot + +The obvious implementation is to reuse `signal()`: complete the task, POST +the payload to the run, let the node read `context.signal` the way +`AwaitSignalNode` does. Rejected. + +`$context['signal']` is ONE slot per run (`FlowRunService.php:74`), and the +walk that wakes consumes it (`:807`). The flow-engine spec already names the +resulting hazard — *"a flow with two awaiting steps MUST NOT have the second +read the answer given to the first"* — and `AwaitSignalNode` only escapes it +because the slot is cleared on consumption, which is a race that holds when +answers are minutes apart and stops holding when two people answer two tasks +in the same worker window. + +The task removes the problem instead of guarding it. Every node's task is a +row addressed by uuid, held in that node's OWN resume slot, and terminality +is a property of that row. Two nodes, two rows, two independent answers, no +shared slot to race over. It also means the node needs no delivery +guarantee: the answer is not in transit, it is in the database. + +`signal()` is still used — with an empty payload — as the WAKE, because it +is the one supported way to park a suspended run as due. It carries no +answer; the answer is read from the task. + +### D-3 — The heartbeat stays, and null is not on the table + +The brief for this change described suspending with `resumeAt: null`. That +is refused for two measured reasons. + +First, `flow-engine`'s spec requires the opposite: *"A node that suspends +waiting on a signal MUST ALSO carry a heartbeat `resumeAt`."* The reason +given there applies verbatim — a completion can land while the run is still +mid-walk and has not suspended yet, and that loses the only wake the run was +going to get. + +Second, and worse: `findAbandonedSignals()` matches +`status = suspended AND resume_at IS NULL AND updated < now - 14 days` +(`FlowRunMapper.php:589-605`; the window is +`FlowRunWorker.php:94`). No shipped node produces a null `resume_at`, so +that reaper has never fired on anything. A user-task node parking on null +would make human approvals its **first** input, and its action is +`STATUS_FAILED` with "Abandoned" — it would fail exactly the long-running +approvals a municipal case is made of, at the two-week mark, silently, with +the task still sitting in someone's inbox. + +So: heartbeat, clamped the same way `AwaitSignalNode` clamps +(`AwaitSignalNode.php:87`, `:98` — 15 minutes default, 5-minute floor +because the stock cron period is five minutes). The cost is a no-op wake per +interval per open task, which is the same cost the platform already pays for +`await-signal` and which the node's own docblock already justifies. + +The consequence is worth stating: a run holding an open user task will never +be reaped by the abandoned-signal path. That is correct — a task that is +open is not abandoned, it is unanswered, and the thing that should act on an +unanswered task is `expires_at` enforcement in `flow-business-timers`, which +knows about business days and escalation. Reaping a run for the sin of +waiting is the wrong instrument. + +### D-4 — `advance` is `0 | N | "all"`, and `null` is REJECTED + +ADR-098 D9 gives the node a budget. The design question is how "unlimited" +is spelled, and the answer is a string. + +The tempting spelling is `null` — "no limit". In PHP and in JSON it is a +catastrophe of coercion, and every step of it is silent: + +- `(int)null === 0`, so a config read as `(int)($config['advance'] ?? 0)` + turns "unlimited" into "park for the worker" — the opposite behaviour, and + a plausible-looking one, so nobody files it. +- `$config['advance'] ?? 'all'` cannot distinguish `null` from ABSENT, + because `??` fires on both. The default and the explicit unlimited become + the same value, so whichever the author picked, they get the other one + half the time. +- `empty(null)` and `empty(0)` are both true, so any guard written as + `if (empty($advance))` collapses the default and unlimited into one branch. +- A JSON round-trip through a definition editor may drop a null key + entirely, so "unlimited" survives a save as "absent". + +`"all"` has none of these properties: it is truthy, it is distinguishable +from absent, it survives a JSON round-trip, and it reads correctly in a +stored definition a human is looking at. The same reasoning is why the +registry publishes `configForm` as ABSENT rather than empty when a node +declares none (`FlowNodeRegistry.php:245-250`) — "did not say" and "said +nothing" must not be the same value. + +`null` is therefore not merely undocumented, it is REFUSED in +`validateConfig()` with a message naming the value and stating the spelling. +Silently accepting it would leave a flow whose author asked for unlimited +running with a budget of zero, which is exactly the class of failure this +decision exists to prevent. + +Alternatives considered: `-1` for unlimited (arithmetic-safe, but `-1 < 1` +is true, so every naive bound check treats it as "already exhausted"); +`PHP_INT_MAX` (works, but a stored definition then contains +`9223372036854775807`, which no author can read as "all"). + +### D-5 — The budget bounds a walk, it does not become a second engine + +`advance: N` does not re-implement stepping. The completion path calls +`FlowRunAdvancer::advance(run: $run, rethrow: true)` — the same call +`FlowService::run()` already makes for a synchronous run +(`FlowService.php:511`) — with a per-walk transition ceiling carried on the +run context and read by the engine's existing loop counter, alongside +`MAX_TRANSITIONS` (`FlowEngine.php:103`, `:325`). Whichever ceiling is lower +wins. There is one walk implementation, one oversight call site, one log +format. + +Three properties fall out, and all three are the point: + +1. **Oversight is not bypassed.** `assertOversightAllows()` runs before every + hop (`FlowEngine.php:425`) and fails closed when a check throws + (`FlowOversightRegistry.php:104-120`). An in-request continuation gets + the identical treatment; there is no "fast path" that skips the gate. +2. **The run row exists before the walk.** `FlowService::run()`'s comment + applies here too: queue first, then advance the queued row, so a + synchronous continuation is still visible in the run log and still + retryable. A continuation that executed invisibly would leave the person + who completed the task with nothing to look at. +3. **Failure degrades to the default.** If the in-request walk throws, the + task is already completed (a separate, committed transaction) and the run + is already due. The worker picks it up on its next pass. The budget is an + optimisation, so its failure mode must be the unoptimised behaviour — + never a lost answer. + +`"all"` stops at the next suspension, the next user task, or an end, because +those are the engine's natural stopping points already; it needs no extra +rule, only the honest documentation that "all" is not "forever". + +### D-6 — The completion outcome goes onto the items + +`AwaitSignalNode.php:294-296` states the rule and the reason: *"Onto every +item rather than into the token, because the steps that follow route per +item; a Switch cannot branch on something only the run holds."* This node +carries it unchanged, under `outcomeKey` (default `task`) rather than +`signalKey`, so a flow containing both nodes does not have them writing over +each other's key by default. + +What goes in the bag is fixed rather than free-form: outcome, comment, +completing identity, performer type, `on_behalf_of`. A delegated completion +must be routable — "approved by the deputy under mandate X" is a different +fact from "approved by the manager", and a flow that cannot see the +difference cannot enforce a four-eyes rule. + +A task that reached a terminal state WITHOUT a completion (terminated, +expired) is marked as such in the same bag. Both are terminal; only one is a +decision, and collapsing them would let an expired approval look like an +approval. + +Non-array items are skipped rather than failing the run — the same +defensive shape as `AwaitSignalNode.php:298-300`. + +### D-7 — Cancellation propagation is wired here because only here knows the mapping + +`flow-task-entity` specifies WHAT must happen (terminate, with a reason, +audited, idempotent, never touching a run-less task). It cannot specify WHEN, +because it has no knowledge of nodes or branches. + +Two sources drive it: + +- **Run terminality** — a listener on the run reaching `completed`, + `stopped`, `failed` or `dead_letter`. Idempotent by necessity: terminality + can be observed by the completing request and again by the worker's + reaper (`FlowRunWorker.php:226-287`), and a second observation must be a + no-op, not a second audit entry. +- **Branch mootness** — an explicit call when the router's taken-exits + decision (`FlowEngine.php:402`, `keepOnlyTakenExits()` at `:410`) leaves a + user-task node's place unreachable (`FlowEngine.php:521`, `:540`). This is + the harder half and the one + worth being explicit about: the Petri net does not raise an event saying + "this place will never be marked", so the node's tasks are resolved by + asking which user-task nodes had live tasks in places the pruning just + cleared. + +Rejected alternative: a periodic sweep that terminates tasks whose run is +terminal. It works, and it is what a retrofit would do, but it leaves a +window — up to one cron period — in which a person can open and act on a +task belonging to a dead run. For an approval that window is a wrong +decision recorded as a right one. + +### D-8 — Idempotent creation lives in the node's resume slot + +The node must never create a second task, and the check must be per node: +`$resume->has('taskUuid')`, exactly the shape `AwaitSignalNode` uses for +`askedAt` (`AwaitSignalNode.php:362-364`). + +The same file also documents why `askedAt` is written ONCE +(`AwaitSignalNode.php:344-348`): a heartbeat that restamps it resets the +record of how long somebody has waited, and "waiting 15 minutes" is the +reading that stops anyone chasing a two-week-old approval. The creation time +here is written once for the same reason. + +Note what this does NOT protect against: two worker passes advancing the +same run concurrently. That is the run-level stale/lease concern the engine +already owns (`FlowRunWorker::reapStale()`, `FlowRunWorker.php:81`, +`:226-287`), not something a node can solve. The task's own `run_uuid` + +`node_id` uniqueness is the belt to this braces, and it belongs to +`flow-task-entity`'s mapper. + +### D-9 — The palette carries the division of labour + +Two nodes that both "wait for an answer" is a choice an author will get +wrong unless the palette tells them. So the descriptions are written as a +pair, and neither is generic: + +- `openregister.await-signal` — *"Pause until another system reports back."* +- `openregister.user-task` — *"Ask a person or an agent to do something, and + wait for their answer."* + +Both are offered at `SCOPE_USER` and `SCOPE_ADMIN`. `AwaitSignalNode.php:168-170` +justifies it for itself — "Waiting grants no privilege" — and it holds +harder here: creating a task grants nothing, because the task service +authorizes the ANSWER independently, which is the entire point of +`flow-task-entity`. + +## Risks / Trade-offs + +- **Every open user task costs a heartbeat wake, forever, until something + ends it.** At the 15-minute default a 30-day approval is ~2,880 no-op + wakes. → Accepted: `AwaitSignalNode.php:78-87` already makes and justifies + this trade, a no-op wake does no work, and the interval is configurable + per node. The real fix for a task nobody answers is `expires_at` + enforcement in `flow-business-timers`, not a shorter fuse here. +- **A run with an open user task can never be reaped as abandoned** (D-3), so + a flow whose task is never answered and never expires holds + `hasActiveRun()` true and its schedule shut. → Mitigated by making + `expires_at` configurable on the node and by `flow-business-timers` + enforcing it. Documented explicitly so nobody rediscovers it as a bug in + the reaper. +- **`advance: "all"` makes a task completion as slow as the rest of the + flow**, and puts a downstream node's side effects inside the completer's + HTTP request. → The default is `0`; `"all"` is opt-in per node; the + ceiling and the oversight gate still apply; and a failure degrades to + worker advancement without losing the completion (D-5). +- **Branch-mootness detection is the part most likely to be incomplete.** + Some graph shapes — a merge that never synchronises, a stage closed by a + sub-flow — may leave a task un-terminated. → The run-terminality + propagation is the backstop: whatever branch pruning misses, run + termination catches. An orphan therefore survives at most as long as its + run, never past it. +- **The node depends on a service that does not exist yet.** `flow-task-entity` + must land first, and its `TaskService` signatures are the contract. → The + dependency is declared, and the ONE thing this change asks of that service + beyond its own spec is a terminality read by task uuid — cheap, and + already implied by the inbox. +- **Two waiting nodes will confuse authors regardless of palette text.** → + Mitigated by D-9, and bounded by the fact that using the wrong one is + recoverable: an `await-signal` step in a human flow is unauthorized and + uninboxed, which is visible immediately, not months later. + +## Migration Plan + +Nothing to migrate. The node is additive: a new registration in +`FlowNodeRegistrationListener`, a new palette entry, no schema change of its +own (`flow-task-entity` owns the tables), no change to any existing node or +endpoint. + +Deploy order is the dependency chain: `flow-definition-versioning` → +`flow-task-entity` → this change. Rollback is removing the registration — +existing flows using the node then fail to resolve their type at run time +with the registry's ordinary "unknown type" error, which is the correct loud +failure and the same one any withdrawn node produces. + +Existing `await-signal` flows are untouched by deployment and by rollback. + +## Open Questions + +- **Does a user-task node ever want more than one task?** A "three of five + approvers" gate is a real municipal shape. Provisionally: ONE task per + node per run, and multi-instance is a later change (or a `flow-parallel-streams` + fan-out over a sub-flow). Deciding otherwise later adds a node config key; + it does not invalidate anything specified here. +- **Where does an escalation land when it re-assigns?** If + `flow-business-timers` reassigns a task on SLA breach, the node's stored + task uuid is still valid, so nothing here changes. If it instead CANCELS + and re-creates, the node's idempotence check would refuse to make the + replacement. Provisionally: escalation reassigns, never recreates — to be + confirmed when `flow-business-timers` is specified. diff --git a/openspec/changes/flow-user-task-node/proposal.md b/openspec/changes/flow-user-task-node/proposal.md new file mode 100644 index 0000000000..19f8896bbd --- /dev/null +++ b/openspec/changes/flow-user-task-node/proposal.md @@ -0,0 +1,206 @@ +--- +kind: code +depends_on: [flow-task-entity] +--- + +# Proposal: flow-user-task-node + +## Summary + +Put a person into the graph. A new node type `openregister.user-task` +creates a task through `flow-task-entity`'s `TaskService`, suspends the run, +and continues when that task is completed — with the completion payload +written onto every item so the next node can branch on it. It carries an +`advance` budget (ADR-098 Decision 9) saying how far the completing request +may push the run before handing back to the worker, and it terminates its +tasks when the run or the branch that owns them dies. + +`openregister.await-signal` is not replaced. It keeps machine-to-machine +signals; this node takes human and agent work, which is the half that needs +an owner, an inbox and a cancellation story. + +## Why + +**The engine can already pause for a person. It just cannot ask one.** +`AwaitSignalNode` suspends a run and waits for `POST +/api/flow-runs/{uuid}/resume` (`appinfo/routes.php:1312`). That is the whole +mechanism, and three things are missing from it, all measurable in the file +itself: + +1. **Nobody is asked.** The node takes an `assignee`, and its own config + help says what it is worth: "A user or group id. Recorded with the + request; it does not by itself restrict who may answer." + (`lib/Service/Flow/Nodes/AwaitSignalNode.php:200-203`). The value is + written into the node's resume slot (`AwaitSignalNode.php:366-371`) and + read by nothing. +2. **Nobody can find the question.** The request is recorded in the run's + own resume state; there is no row anywhere that says "this person owes an + answer". "What is waiting for me?" is answerable only by walking + suspended runs and reading a JSON column. `flow-task-entity` builds the + row and the inbox that fixes this; nothing in the graph creates one. +3. **Anyone can answer.** `FlowRunController::resume()` checks only that the + caller may RUN the flow (`lib/Controller/FlowRunController.php:423-436`). + It never checks that the caller is the person being waited on, because + there is no person being waited on. + +**A signal is the wrong shape for human work.** `signal()` writes the +payload to `$context['signal']` — ONE slot per run +(`FlowRunService.php:74`, `:530`), consumed and cleared by the walk it wakes +(`:807`). That is correct for "one question, one answer" and wrong for work +that gets claimed, reassigned, delegated, chased and sometimes cancelled +before anybody touches it. None of those verbs have anywhere to live on a +context key. + +**And a paused run has no completion latency control.** `signal()` parks the +run as due and returns; the worker advances it on its next pass, and the +stock system cron is every five minutes (`AwaitSignalNode.php:82-83`). For +an approval that is the right trade, and `FlowRunService::signal()`'s own +docblock says so. For a form a person just submitted and is watching, five +minutes of "nothing happened" is the trade being made backwards. The engine +already has the other path — `FlowService::run()` advances inline with +`rethrow: true` when a caller asked to wait (`FlowService.php:511`) — but no +node can ask for it. ADR-098 Decision 9 makes it a per-node budget. + +**The retrofit bug is cancellation.** When a run is stopped, or a parallel +branch settles a choice, the tasks it created are moot. If nothing +terminates them they stay in inboxes as actionable work forever, and people +do them. `flow-task-entity` specifies the propagation; this change is what +supplies the run-terminal and branch-moot events that drive it, because it +is the only thing that knows which node created which task. + +## What Changes + +- **A new node, `openregister.user-task`**, in `lib/Service/Flow/Nodes/`, + implementing `IFlowNode`, `IFlowNodeConfigKeys` and `IFlowNodeConfigForm` + and registered like every other built-in through + `lib/Listener/FlowNodeRegistrationListener.php`. Its `configForm()` is + served by `GET /api/flow/node-catalog` + (`lib/Controller/FlowController.php:213`, `appinfo/routes.php:508`), which + already publishes `configForm` for any node declaring one + (`FlowNodeRegistry.php:243-250`) — so the builder gets the form with no + editor change. +- **The config says what task to create**: title and description templates, + candidate users / groups / role, routing strategy, priority, `due_at` and + `expires_at` references, and the `outcome` vocabulary the flow will branch + on. Every one of these is passed THROUGH to `TaskService::create()`; this + node validates and templates, it does not re-model the task. +- **It suspends and resumes like `AwaitSignalNode`, deliberately.** First + pass: create the task, record it in the node's own resume slot + (`FlowNodeResumeState`, `lib/Service/Flow/FlowNodeResumeState.php:39-65`), + throw `FlowSuspension`. Later passes: ask the TASK whether it is terminal, + not the run context. Two user-task nodes in one flow therefore keep + independent state, which the per-node slot already guarantees — that is + the case a flat context bag breaks silently. +- **The completion payload lands on every item**, under a configured + `outcomeKey` (default `task`), carrying the outcome, the comment, the + completing identity, the performer type and any `on_behalf_of`. Onto the + ITEMS rather than the run, for the reason `AwaitSignalNode.php:294-296` + gives: *"a Switch cannot branch on something only the run holds."* +- **A rejecting outcome is a BRANCH, not a failure.** Default is to carry on + and let the author route on the outcome; `failOnReject` stays opt-in, the + same shape as `AwaitSignalNode.php:277-287`. Being told no is the flow + working. +- **An `advance` budget** (ADR-098 D9) on the node: `0` (default) parks the + run for the worker exactly as `signal()` does today; `N` continues at most + N transitions inside the completing request; `"all"` continues until the + next suspension, the next user task, or an end. Unlimited is spelled + `"all"` — **never `null`**, because a null budget and an absent one + coerce identically in PHP and JSON and the accident would be "run to + completion synchronously" (design.md, D-4). Every value stays bounded by + `FlowEngine::MAX_TRANSITIONS` (`lib/Service/Flow/FlowEngine.php:103`, + 1000) and by the pre-hop oversight check + (`FlowEngine.php:425`, `assertOversightAllows()`), which is fail-closed + (`FlowOversightRegistry.php:104-120`) and stays so on the in-request path. +- **Cancellation propagation is wired here.** A run reaching a terminal + status terminates every non-terminal task created by any of its user-task + nodes; a branch decision that makes a node unreachable terminates that + node's task with a reason naming the branch. The reason is recorded, the + task disappears from inboxes as actionable, and the audit names the + propagation source as actor. +- **The heartbeat is kept.** `flow-engine`'s spec already requires it — *"A + node that suspends waiting on a signal MUST ALSO carry a heartbeat + `resumeAt`"* — and the reason holds here: a completion can land while the + run is mid-walk and has not suspended yet. Measured, it holds harder than + the spec says: `findAbandonedSignals()` matches only + `resume_at IS NULL` (`lib/Db/FlowRunMapper.php:589-605`), and **no shipped + node ever suspends with null** — `WaitNode.php:192` and + `AwaitSignalNode.php:266` are the only two `FlowSuspension` throws in + `lib/`, and both pass a time. A user-task node that parked on null would + be the first run in the fleet the 14-day reaper + (`FlowRunWorker.php:94`, `:311-349`) could actually FAIL — and it would + fail approvals that are merely slow. + +## What does NOT change + +Each of these is a named change in the ADR-098 chain and is explicitly OUT +of scope here: + +- **`flow-task-entity`** — the `openregister_tasks` table, `TaskService`, + `TaskAuthorizationService`, the inbox query, the CMMN lifecycle, the + performer model, the routing strategies, the append-only audit. This node + is a CONSUMER of all of it. It defines no task field and no lifecycle + verb of its own. +- **`flow-task-forms`** — structured completion payloads over the lifecycle + transition `inputs` contract and the nc-vue form family. This node's + completion payload is a typed but hand-specified bag; where the form comes + from is that change's question. +- **`flow-task-inbox-projections`** — `INotificationManager` notifications + and the CalDAV VTODO projection with its authorizing write-back listener. + Creating a task here notifies nobody and writes no calendar entry. +- **`flow-business-timers`** — SLA arithmetic, business days, escalation + matrices, opschorting, and the sweep that acts on `expires_at`. This node + configures where those two timestamps come FROM; it never enforces one. +- **`flow-bpmn-interchange`** — mapping `bpmn:userTask` onto this node on + import and back out on export. This change ships the node the mapping will + target; it ships no BPMN. +- **`openregister.await-signal` itself.** It stays, unmodified, with its + heartbeat, its `failOnReject` and its nudge-is-not-an-answer rule. The + division of labour is stated once and held: **a signal is for a system + that will call back; a user task is for a performer who must be found, + told, and allowed to say no.** A flow waiting on a payment provider keeps + using `await-signal`; a flow waiting on a case handler uses this. + +## Capabilities + +### New Capabilities +- `flow-user-task-node`: the `openregister.user-task` step node — task + creation from node config, suspend/resume against task terminality, + per-item outcome placement, rejection-as-branch, the `advance` budget, and + cancellation propagation from run and branch terminality onto the tasks + the node created. + +### Modified Capabilities + + +## Impact + +- **Affected code**: new `lib/Service/Flow/Nodes/UserTaskNode.php`; new + `lib/Service/Flow/FlowTaskBridge.php` (node ↔ `TaskService`, plus the + advance-budget continuation); `lib/Listener/FlowNodeRegistrationListener.php` + (registers the node); a listener on task completion that signals the run + and, per budget, advances it; a listener on run terminality that + propagates cancellation. `lib/Service/Flow/Nodes/AwaitSignalNode.php` is + NOT touched. +- **Affected APIs**: no new endpoint. Completion happens on + `flow-task-entity`'s task verbs, which are authorized fail-closed; + `POST /api/flow-runs/{uuid}/resume` keeps working for `await-signal` and + MUST NOT be able to complete a user task — that would reintroduce the very + hole at `FlowRunController.php:423-436` this chain exists to close. +- **Affected UI**: the node appears in the builder palette automatically via + `FlowNodeRegistry::palette()`; no editor change is required for its form. + The task inbox is `flow-task-entity`'s surface, not this change's. +- **Affected apps**: none required. Consumers arrive with + `flow-approval-consolidation`; hermiq, procest and openconnector gain a + target for their human steps but migrate in their own changes. +- **Depends on**: `flow-task-entity` (which itself depends on + `flow-definition-versioning`). A `node_id` recorded on a task is a pointer + into a definition, and it only means anything while that definition is + pinned for the life of the run. +- **ADRs**: ADR-098 D1 (one engine), D3 (performer types — an agent + completes a user task through the same verbs), D9 (the `advance` budget, + and `"all"` never `null`); ADR-065 (the node joins the single engine); + ADR-031 (declarative-vs-imperative — argued in design.md). diff --git a/openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md b/openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md new file mode 100644 index 0000000000..6976f49fbc --- /dev/null +++ b/openspec/changes/flow-user-task-node/specs/flow-user-task-node/spec.md @@ -0,0 +1,402 @@ +## Purpose + +One step type that puts a performer into a flow graph: it creates a task, +parks the run until that task reaches a terminal state, hands the outcome to +the nodes downstream so they can route on it, and terminates its task when +the run or the branch that owns it dies. + +## ADDED Requirements + +### Requirement: A user-task step creates exactly one task and suspends the run + +The system SHALL provide a step node type `openregister.user-task`. On its +first firing with items, the node SHALL create ONE task through the +`flow-tasks` task service and SHALL then suspend the run. + +The created task SHALL carry the run's uuid and this node's id as +provenance, so the task can be traced back to the step that asked for it and +so cancellation propagation can find it. + +The node SHALL NOT create a second task on a later firing of the same node +in the same run. Whether a task already exists SHALL be determined from THIS +node's own resume slot, not from run-level state — a heartbeat wake, a lost +delivery, or a duplicated worker pass MUST NOT produce a second task in +somebody's inbox. + +A firing that carries NO items SHALL create no task and SHALL NOT suspend +the run: suspension is a run-level act, and an empty branch reaching this +node is the normal case in a priority-ordered graph. + +Task creation SHALL be delegated in full. The node SHALL NOT define a task +field, a lifecycle state, a routing strategy or an authorization rule of its +own; every one of those belongs to `flow-tasks`. + +#### Scenario: The first firing produces a task and a suspended run + +- **GIVEN** a flow whose graph contains one `openregister.user-task` node +- **WHEN** the run reaches that node carrying one item +- **THEN** exactly one task MUST exist carrying the run's uuid and the node's + id +- **AND** the run MUST be suspended +- @e2e a flow with a user task suspends and the task appears in the inbox + +#### Scenario: A heartbeat wake does not create a second task + +- **GIVEN** a run suspended on a user-task node whose task is still open +- **WHEN** the worker wakes it on its heartbeat and the node fires again +- **THEN** the task count for that run and node MUST still be one +- **AND** the run MUST suspend again +- @e2e exclude covered by UserTaskNode unit tests over a resumed context + +#### Scenario: An empty branch creates nothing + +- **GIVEN** a routing node that sent every item down a sibling branch +- **WHEN** the user-task node on the empty branch fires with no items +- **THEN** no task MUST be created +- **AND** the run MUST NOT suspend +- @e2e exclude engine-internal suspend rule — covered by UserTaskNode unit + tests + +### Requirement: The run continues on task TERMINALITY, never on a nudge + +A suspended user-task node SHALL continue the run only when its task has +reached a terminal state. While the task is non-terminal the node SHALL +suspend again. + +The node SHALL read the TASK to decide this. It SHALL NOT accept the run +context's signal slot as an answer: a signal is one slot per run, consumed +by the walk it wakes, and a flow with two user-task nodes would otherwise +have the second read an answer given to the first. + +Completing the task SHALL make the run due. A completion that is delivered +while the run is still mid-walk, or whose delivery fails, SHALL NOT strand +the run: the node SHALL suspend with a heartbeat `resumeAt` so a lost wake +costs one heartbeat interval rather than the flow. The node SHALL NOT +suspend with a null `resumeAt`. + +`POST /api/flow-runs/{uuid}/resume` SHALL NOT be able to complete a user +task. That endpoint authorizes only that the caller may run the FLOW; a user +task is completed through the task service's authorized verbs or not at all. +A resume posted against a run suspended on a user-task node SHALL leave the +task and the run's suspension unchanged. + +#### Scenario: Completing the task advances the run + +- **GIVEN** a run suspended on a user-task node +- **WHEN** the assignee completes the task +- **THEN** the run MUST become due +- **AND** on the next advance the node MUST NOT suspend again +- @e2e completing a task from the inbox advances its flow run + +#### Scenario: A claim is not a completion + +- **GIVEN** a run suspended on a user-task node whose task is unclaimed +- **WHEN** a pool member claims it +- **THEN** the run MUST remain suspended +- @e2e exclude covered by UserTaskNode unit tests over a claimed task + +#### Scenario: The resume endpoint cannot answer for a performer + +- **GIVEN** a run suspended on a user-task node, and an authenticated user + who may run the flow but is not the task's performer +- **WHEN** that user posts a decision to the run's resume endpoint +- **THEN** the task MUST remain non-terminal +- **AND** the run MUST remain suspended +- @e2e a flow-runner who is not the performer cannot answer a user task + +#### Scenario: A suspended user task is reachable by the clock + +- **GIVEN** a run suspended on a user-task node +- **WHEN** its persisted resume time is read +- **THEN** it MUST NOT be null +- @e2e exclude covered by a unit test asserting the thrown suspension + +### Requirement: The outcome is written onto every item, not only onto the run + +When the node continues, it SHALL write the task's completion result onto +EVERY item it passes on, under a configurable key defaulting to `task`. + +The written value SHALL carry at minimum the outcome, the comment where one +was given, the completing identity, the performer type, and the +`on_behalf_of` identity where the completion was delegated. + +Writing it onto the items rather than into run-level context is normative, +not incidental: the steps that follow route PER ITEM, and a switch cannot +branch on something only the run holds. + +The node SHALL leave any item that is not a value bag untouched rather than +failing the run. + +#### Scenario: A downstream switch branches on the outcome + +- **GIVEN** a user-task node followed by a switch keyed on the outcome +- **WHEN** the task is completed with a rejecting outcome +- **THEN** every item leaving the node MUST carry that outcome under the + configured key +- **AND** the switch MUST take the rejection edge +- @e2e a rejected task routes the flow down its rejection branch + +#### Scenario: A delegated completion names both identities on the item + +- **GIVEN** a task completed by a delegate acting on behalf of the assignee +- **WHEN** the run continues +- **THEN** the item payload MUST name the delegate as the completing + identity and the assignee as the on-behalf-of identity +- @e2e exclude covered by UserTaskNode unit tests over a delegated + completion + +### Requirement: A rejecting outcome is a branch, not a failure + +A task completed with a rejecting or returning outcome SHALL by default +continue the run so the author can route on it. It SHALL NOT fail the run, +and SHALL NOT be recorded as an error. + +The node SHALL offer an opt-in setting that turns a rejection into a +deliberate stop for the flows where a no really is a fault. When that +setting is off — the default — the run's status after a rejection SHALL be +indistinguishable from the run's status after an approval. + +A task that reached a terminal state WITHOUT a completion — terminated, +expired or cancelled — SHALL be distinguishable downstream from one that was +completed with a rejecting outcome. Both are terminal; only one of them is a +person's decision. + +#### Scenario: A rejection carries on by default + +- **GIVEN** a user-task node with the fail-on-reject setting off +- **WHEN** its task is completed with a rejecting outcome +- **THEN** the run MUST continue past the node +- **AND** the run MUST NOT be marked failed +- @e2e exclude covered by UserTaskNode unit tests + +#### Scenario: A flow that opts in stops on a rejection + +- **GIVEN** a user-task node with the fail-on-reject setting on +- **WHEN** its task is completed with a rejecting outcome +- **THEN** the run MUST end with a reason naming the rejection +- @e2e exclude covered by UserTaskNode unit tests + +#### Scenario: A terminated task is not a rejection + +- **GIVEN** a task terminated by expiry rather than completed +- **WHEN** the run continues past the node +- **THEN** the item payload MUST distinguish it from a rejecting completion +- @e2e exclude covered by UserTaskNode unit tests over a terminated task + +### Requirement: Several user-task nodes in one flow keep independent state + +A flow containing more than one `openregister.user-task` node SHALL keep +each node's task reference, question and progress in that node's own resume +slot. + +One node's task SHALL NOT be readable or overwritable by another node, and +completing one node's task SHALL NOT continue a different node. A flow with +two sequential approvals SHALL require two completions. + +The record of WHEN each task was created SHALL be written once and SHALL NOT +be restamped by a heartbeat wake. A creation time that resets every +heartbeat would report every long-waiting task as minutes old — which is +exactly the reading that stops anyone chasing it. + +#### Scenario: Two approvals require two answers + +- **GIVEN** a flow with two sequential user-task nodes +- **WHEN** the first node's task is completed +- **THEN** the run MUST suspend again on the second node +- **AND** a second, distinct task MUST exist +- @e2e a two-approval flow requires both approvals + +#### Scenario: A heartbeat does not restamp the asked-at time + +- **GIVEN** a user-task node suspended for several heartbeat intervals +- **WHEN** the node's recorded creation time is read +- **THEN** it MUST equal the time the task was created +- @e2e exclude covered by a clock-controlled unit test + +### Requirement: The advance budget says how far a completion may push the run + +The node SHALL accept an `advance` budget with exactly three shapes: + +- `0` — the DEFAULT. The completion parks the run as due and returns; the + worker advances it on its next pass. +- a positive integer `N` — the completing request continues the run for at + most N transitions, then leaves the remainder to the worker. +- the string `"all"` — the completing request continues until the run + suspends again, reaches another user task, or ends. + +Unlimited SHALL be spelled `"all"`. The system SHALL NOT accept `null`, +an empty string, or an absent value as a synonym for unlimited, and SHALL +reject `null` at config validation naming the value. A missing budget SHALL +mean `0`. + +Every budget SHALL remain bounded by the engine's existing transition +ceiling and by the pre-hop oversight check. An in-request continuation SHALL +be subject to the SAME oversight veto as a worker-driven one, and an +oversight check that cannot complete SHALL refuse the hop rather than be +skipped because the caller was in a hurry. + +An error raised while continuing in-request SHALL NOT lose the completion: +the task SHALL remain completed and the run SHALL remain advanceable by the +worker. The completing caller SHALL be told that the task was accepted even +when the continuation did not finish. + +#### Scenario: The default parks for the worker + +- **GIVEN** a user-task node with no `advance` configured +- **WHEN** its task is completed +- **THEN** the completing request MUST return with the run still suspended + and due +- **AND** the run MUST advance on the next worker pass +- @e2e exclude covered by unit tests over the completion listener + +#### Scenario: A budget of "all" runs to the next stopping point + +- **GIVEN** a user-task node with `advance` set to `"all"`, followed by two + ordinary steps and an end node +- **WHEN** its task is completed +- **THEN** the completing request MUST return the run already ended +- @e2e completing a task with an "all" budget finishes the run in one request + +#### Scenario: null is refused, not read as unlimited + +- **GIVEN** a flow saved with `advance` set to `null` on a user-task node +- **WHEN** the node's configuration is validated +- **THEN** validation MUST fail with an error naming the value and stating + that unlimited is spelled `"all"` +- @e2e exclude covered by UserTaskNode config-validation unit tests + +#### Scenario: An oversight veto still applies in-request + +- **GIVEN** a user-task node with `advance` set to `"all"` and an oversight + check that vetoes the next hop +- **WHEN** its task is completed +- **THEN** the run MUST stop with the veto's reason and the check's id +- **AND** the hop MUST NOT be taken +- @e2e exclude covered by unit tests with a vetoing oversight check + +#### Scenario: A failed continuation does not lose the answer + +- **GIVEN** a user-task node with a positive `advance` budget and a + downstream step that throws +- **WHEN** its task is completed +- **THEN** the task MUST remain completed +- **AND** the completing caller MUST be told the task was accepted +- @e2e exclude covered by unit tests with an injected downstream failure + +### Requirement: A task whose run or branch has died is terminated, not orphaned + +When a run reaches a terminal status, every non-terminal task created by any +user-task node in that run SHALL be terminated with a reason naming the run +and its terminal status. + +When a branch decision makes a user-task node unreachable — a competing +branch settled the choice, or the stage the node belonged to closed — that +node's non-terminal task SHALL be terminated with a reason naming the +branch. + +A terminated task SHALL disappear from every inbox as actionable work, and +SHALL NOT be deleted: its audit trail records who or what terminated it. + +Propagation SHALL be idempotent. Run terminality can be observed more than +once — by the completing request and by the worker's reaper — and a second +observation SHALL be a no-op rather than a second termination entry. + +Propagation SHALL NEVER reach a task that carries no run uuid. + +#### Scenario: Stopping a run empties its inboxes + +- **GIVEN** a run suspended on two user-task nodes with two open tasks + assigned to two people +- **WHEN** the run is stopped +- **THEN** both tasks MUST be terminated with a reason naming the run +- **AND** neither MUST appear as actionable in its assignee's inbox +- @e2e stopping a run removes its tasks from the assignees' inboxes + +#### Scenario: A losing parallel branch takes its task with it + +- **GIVEN** a flow with two parallel branches, each with a user-task node, + where settling either branch makes the other moot +- **WHEN** the first branch's task is completed +- **THEN** the second branch's task MUST be terminated with a reason naming + the branch +- @e2e exclude covered by cancellation-propagation unit tests + +#### Scenario: Observing terminality twice terminates once + +- **GIVEN** a stopped run whose task was already terminated by propagation +- **WHEN** the worker observes the run's terminality again +- **THEN** no second termination MUST be recorded +- @e2e exclude covered by cancellation-propagation unit tests + +### Requirement: The node describes its own form, served from the node catalog + +The node SHALL declare its configuration vocabulary and a server-driven form +describing every field it accepts, and both SHALL be published through the +existing flow node catalog endpoint so a builder needs no hardcoded field +table. + +The form SHALL cover at minimum: what the task is (title and description +templates), who may perform it (candidate users, groups or role, plus the +routing strategy and fallback), how urgent it is (priority), when it is due +and when it expires, the outcome vocabulary the flow will branch on, the +item key the outcome is written under, whether a rejection fails the run, +the heartbeat interval, and the `advance` budget. + +The node SHALL be offered in both the administrator and the ordinary-user +palette scopes: asking a person to do something grants no privilege the +caller did not already have, and the task service authorizes the answer +independently. + +A configuration naming no candidate performer of any kind SHALL be rejected +at validation. A task nobody can be found for is not a task; leaving it to +fail at run time buries the mistake in a suspended run. + +#### Scenario: The catalog serves the node's form + +- **GIVEN** an authenticated caller requesting the flow node catalog +- **WHEN** the response is read +- **THEN** it MUST contain an entry for `openregister.user-task` carrying a + non-empty form description +- @e2e the node catalog offers the user-task node with its form + +#### Scenario: A task with no possible performer is refused at save time + +- **GIVEN** a user-task node configured with no candidate user, group or role + and no routing fallback +- **WHEN** its configuration is validated +- **THEN** validation MUST fail with an error saying no performer can be + resolved +- @e2e exclude covered by UserTaskNode config-validation unit tests + +### Requirement: The signal node keeps machine-to-machine work + +`openregister.await-signal` SHALL remain available and SHALL be unchanged by +this capability. Its heartbeat, its nudge-is-not-an-answer rule and its +opt-in fail-on-reject SHALL keep working exactly as before. + +The division SHALL be stated in both nodes' palette descriptions so an +author picks correctly without reading the source: a signal is for a system +that will call back; a user task is for a performer who has to be found, +told, and allowed to say no. + +A flow MAY contain both. A signal delivered to a run suspended on a +user-task node SHALL NOT continue that node, and completing a task SHALL NOT +continue an awaiting signal node. + +#### Scenario: An existing signal flow is unaffected + +- **GIVEN** a flow using `openregister.await-signal` that worked before this + capability +- **WHEN** it is run and signalled +- **THEN** it MUST behave identically to before +- @e2e exclude regression covered by the existing AwaitSignalNode tests + +#### Scenario: The two wait mechanisms do not answer each other + +- **GIVEN** a flow containing both an awaiting signal node and a user-task + node +- **WHEN** the run is suspended on the user-task node and a signal is + delivered +- **THEN** the user task MUST remain non-terminal and the run MUST remain + suspended +- @e2e exclude covered by unit tests over a mixed flow diff --git a/openspec/changes/flow-user-task-node/tasks.md b/openspec/changes/flow-user-task-node/tasks.md new file mode 100644 index 0000000000..c03dd8ee90 --- /dev/null +++ b/openspec/changes/flow-user-task-node/tasks.md @@ -0,0 +1,148 @@ +# Tasks: flow-user-task-node + +## 1. The node + +- [ ] 1.1 `lib/Service/Flow/Nodes/UserTaskNode.php` implementing `IFlowNode`, + `IFlowNodeConfigKeys` and `IFlowNodeConfigForm`. Follow + `lib/Service/Flow/Nodes/AwaitSignalNode.php` for shape: EUPL-1.2 + header, `@spec` on every method, a file docblock that states the + division of labour with `await-signal` and the reason the heartbeat + exists. `getId()` returns `openregister.user-task`; + `isAvailableForScope()` allows `SCOPE_ADMIN` and `SCOPE_USER`. +- [ ] 1.2 `configForm()` + `configKeys()` covering title/description + templates, candidate users/groups/role, routing strategy and fallback, + priority, `dueAt`/`expiresAt` references, outcome vocabulary, + `outcomeKey` (default `task`), `failOnReject`, `heartbeatMinutes`, + `advance`. No editor change needed — + `FlowNodeRegistry::palette()` already publishes `configForm` + (`lib/Service/Flow/FlowNodeRegistry.php:243-250`). +- [ ] 1.3 `validateConfig()` — refuse a config naming no candidate user, + group, role or fallback; refuse `advance: null` with a message naming + the value and stating that unlimited is spelled `"all"`; refuse an + `advance` that is neither `0`, a positive integer, nor `"all"`. +- [ ] 1.4 Register the node in `lib/Listener/FlowNodeRegistrationListener.php` + alongside the existing built-ins. Do not touch `AwaitSignalNode.php`. + +## 2. Suspend and resume + +- [ ] 2.1 First-firing path: no items → return items unchanged and do NOT + suspend; items present and no task in this node's resume slot → create + one task via `flow-tasks`' `TaskService` with `run_uuid` + `node_id`, + store its uuid and the creation time in the slot + (`FlowNodeResumeState`), then throw `FlowSuspension`. +- [ ] 2.2 Heartbeat: suspend with a non-null `resumeAt`, defaulting to 15 + minutes and clamped to a 5-minute floor, matching + `AwaitSignalNode.php:87` and `:98`. NEVER `resumeAt: null` — that is + the only shape `FlowRunMapper::findAbandonedSignals()` + (`lib/Db/FlowRunMapper.php:589-605`) reaps, and it would FAIL slow + approvals at 14 days (`lib/Cron/FlowRunWorker.php:94`). +- [ ] 2.3 Continuation path: read terminality from the TASK by uuid, never + from `$context['signal']`. Non-terminal → suspend again without + restamping the creation time. Terminal → continue. Idempotence is + per node via the resume slot, so two user-task nodes in one flow keep + independent state. + +## 3. Outcome and rejection + +- [ ] 3.1 Write the completion result onto EVERY item under `outcomeKey` — + outcome, comment, completing identity, performer type, `on_behalf_of` + — and mark a task that went terminal WITHOUT a completion (terminated, + expired) distinguishably. Non-array items are skipped, not fatal. + Rationale is `AwaitSignalNode.php:294-296`: a Switch cannot branch on + something only the run holds. +- [ ] 3.2 Rejection is a BRANCH: continue by default, `failOnReject` opt-in + raising `FlowStop`, same shape as `AwaitSignalNode.php:277-287`. + +## 4. The advance budget + +- [ ] 4.1 Completion listener: signal the run with an EMPTY payload so it is + parked as due (`FlowRunService::signal()`), then honour the node's + budget — `0` returns immediately for the worker; `N` and `"all"` call + `FlowRunAdvancer::advance(run: $run, rethrow: true)`, the same path + `FlowService.php:511` already uses. +- [ ] 4.2 Per-walk transition ceiling carried on the run context and read by + the engine's existing loop counter alongside `MAX_TRANSITIONS` + (`lib/Service/Flow/FlowEngine.php:103`, `:325`); the lower ceiling + wins. No second walk implementation, no second oversight call site — + `assertOversightAllows()` (`FlowEngine.php:425`) still gates every hop + and still fails closed. +- [ ] 4.3 Degradation: a throw during in-request continuation leaves the task + completed and the run due for the worker, and the completing caller is + told the task was accepted. The budget is an optimisation; its failure + mode is the unoptimised behaviour. + +## 5. Cancellation propagation + +- [ ] 5.1 Run-terminality listener: on `completed`, `stopped`, `failed` or + `dead_letter` (`lib/Db/FlowRun.php` STATUS constants), terminate every + non-terminal task created by any user-task node in that run, reason + naming the run and its status, propagation source as actor. + Idempotent — terminality is observable twice (completing request and + `FlowRunWorker::reapStale()`, `lib/Cron/FlowRunWorker.php:226-287`). +- [ ] 5.2 Branch-mootness call: when `keepOnlyTakenExits()` + (`FlowEngine.php:410`, `:540`) prunes a place holding a live + user-task node's task, terminate it with a reason naming the branch. + Never reaches a task with `run_uuid` null. + +## 6. Tests + +- [ ] 6.1 Node unit tests: one task per node per run across a heartbeat wake; + empty firing creates nothing and does not suspend; claim is not + completion; terminality read from the task and never from the signal + slot; asked-at not restamped; two nodes in one flow requiring two + completions. +- [ ] 6.2 Config-validation table: `advance` accepting `0`, `3`, `"all"` and + REFUSING `null`, `""`, `-1` and `"unlimited"`, each with the value in + the message; a config with no resolvable performer refused. +- [ ] 6.3 Budget and oversight tests: `0` leaves the run suspended-and-due; + `"all"` finishes the run in-request; a vetoing oversight check stops + the in-request walk with the check id; an injected downstream throw + leaves the task completed and the run advanceable. +- [ ] 6.4 Propagation tests: stopping a run terminates both its tasks with a + reason; a losing parallel branch takes its task with it; a second + observation of terminality records nothing; a `run_uuid`-null task is + untouched. +- [ ] 6.5 Playwright coverage for the eight `@e2e`-marked scenarios in + `specs/flow-user-task-node/spec.md`, including the negative one: a + caller who may run the flow but is not the performer CANNOT answer the + task through `POST /api/flow-runs/{uuid}/resume`. + +## Acceptance criteria + +- A flow containing a user-task node creates exactly one task, suspends, and + continues only when that task is terminal — verified across a heartbeat + wake and a duplicated worker pass. +- No code path suspends a user-task node with `resumeAt: null`, and no run + holding an open user task becomes eligible for the abandoned-signal + reaper. +- `advance: null` is refused at config validation; `0`, `N` and `"all"` are + the only accepted shapes, and `"all"` stops at the next suspension, the + next user task, or an end. +- Every in-request continuation passes the same fail-closed oversight check + as a worker-driven one, and stays under `FlowEngine::MAX_TRANSITIONS`. +- Terminating a run empties its assignees' inboxes of that run's tasks, with + reasons recorded and no second entry on a second observation. +- A downstream Switch can branch on the outcome without reading run context, + and can tell a rejecting completion from an expiry. +- `lib/Service/Flow/Nodes/AwaitSignalNode.php`, + `lib/Controller/FlowRunController.php` and + `lib/Service/Flow/FlowRunService.php::signal()` semantics are unchanged; + existing `await-signal` flows behave identically. +- Nothing in this change sends a notification, writes a VTODO, defines a + form, computes an SLA, or maps BPMN. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- Every new PHP file carries `@license EUPL-1.2` and + `@copyright 2026 Conduction B.V.`; every public/protected method carries a + `@spec openspec/specs/flow-user-task-node/spec.md` anchor. +- Regression check against opencatalogi and softwarecatalog: both consume + the shared engine, so the check is that their suites are green and no + existing node, endpoint or service signature changed. +- Depends on `flow-task-entity` (itself on `flow-definition-versioning`) — + a `node_id` on a task is a pointer into a definition and means nothing + until that definition is pinned for the life of the run. +- References ADR-098 (D1 one engine, D3 performer types, D9 the advance + budget and `"all"` never `null`), ADR-065 (the node joins the single + engine), ADR-031 (declarative-vs-imperative, design.md D-1). diff --git a/openspec/changes/notification-scheduled-filter-grammar/.openspec.yaml b/openspec/changes/notification-scheduled-filter-grammar/.openspec.yaml new file mode 100644 index 0000000000..6529e830bb --- /dev/null +++ b/openspec/changes/notification-scheduled-filter-grammar/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-22 diff --git a/openspec/changes/notification-scheduled-filter-grammar/design.md b/openspec/changes/notification-scheduled-filter-grammar/design.md new file mode 100644 index 0000000000..41c44bca32 --- /dev/null +++ b/openspec/changes/notification-scheduled-filter-grammar/design.md @@ -0,0 +1,482 @@ +# Design: notification-scheduled-filter-grammar + +## Context + +See `proposal.md` — *Why*. The short version needed here: the grammar the +evaluator executes and the grammar the validator accepts are two different +grammars, written independently in two files, and the validator's is strictly +larger. Twenty-four filter entries in the fleet fall in the gap. + +Three facts constrain every decision below, and all three were verified against +source rather than assumed. + +**1. The validator's output is discarded.** +`SchemaMapper::validateNotificationsAnnotation()` builds the errors and then +throws them away: + +```php +// lib/Db/SchemaMapper.php:1364-1381 +$errors = (new NotificationAnnotationValidator())->validate($shape); +if (count($errors) === 0) { + return; +} +// … "A malformed OPTIONAL notification annotation must NOT block the schema +// itself — and, on config import, the entire register + every schema …" +$this->logger->warning('Schema "' . $schema->getSlug() . '" has invalid …'); +``` + +There is no `422` in `lib/Db/SchemaMapper.php`, and no other caller of the +validator exists in `lib/` outside its own file and +`lib/Service/Handoff/HandoffAnnotationValidator.php`'s docblock reference. +`openspec/specs/notificatie-engine/spec.md:846-853` nonetheless says the +validator "MUST reject, with HTTP 422". The spec has been wrong since the +`notification-engine-scheduled-conditions` change, and this design does not +propose to make the code match it — the import-safety reasoning in that comment +is sound. It proposes to make the spec match the code and to put the blocking +enforcement where it can actually block. See Decision 5. + +**2. The `created` path has its own filter grammar, and it is not this one.** + +```php +// lib/Service/Notification/AnnotationNotificationDispatcher.php:1686-1723 +$field = (string)($filter['field'] ?? ''); +if ($field === '') { return false; } +$operator = (string)($filter['operator'] ?? 'equals'); +$actual = ($data[$field] ?? null); +$actualStr = ''; +if (is_scalar($actual) === true) { $actualStr = (string)$actual; } +if ($operator === 'in' || $operator === 'notIn') { … } +return $actualStr === (string)($filter['value'] ?? ''); +``` + +Different **shape** (one `{field, operator, value|values}` object, not a map of +entries), different **comparison** (everything cast to string, so `true` and +`"1"` are equal and `1` and `"1"` are equal), and a different **operator set** +(`equals`, `in`, `notIn` — no dates at all). `withinNext`/`olderThan`/`before` +exist nowhere in it. + +**3. The scan is fully in-memory and knows it.** +`ScheduledNotificationJob` loads a whole schema's objects and filters in PHP, +bounded by `MAX_OBJECTS_PER_FIRE = 5000` and a rotating offset window +(`lib/BackgroundJob/ScheduledNotificationJob.php:69`, `:329-377`), with the +deferred plan recorded in the constant's docblock: + +```php +// lib/BackgroundJob/ScheduledNotificationJob.php:64-67 +// TODO(PERF-3): push the trigger `filter` into SQL (a paged findBySchema with +// _filter+_limit in lib/Db/MagicMapper) and add a per-schema watermark for +// delta scans, so we no longer load the whole table into PHP and filter +// in-memory. Until then this cap bounds the blast radius. +``` + +## ADR-031: declarative vs imperative + +ADR-031 makes `x-openregister-*` the default path and requires an imperative +alternative to be justified. This change is a direct application of it, in both +directions. + +**The surface stays declarative, and that is the whole point.** A scheduled +notification rule is a statement in a register annotation: *these objects, this +often, this message, these people*. The imperative alternative — each app +writing its own `TimedJob` that queries `MagicMapper` and calls +`AnnotationNotificationDispatcher::dispatchWithSchema()` (public at +`lib/Service/Notification/AnnotationNotificationDispatcher.php:211`) — is +exactly what the census shows people do NOT want to do: 23 rules across three +teams were expressed declaratively, in three dialects, because the declarative +surface was the obvious place to express them. The failure was not that they +chose the declarative path; it is that the declarative path silently accepted +sentences it could not read. Narrowing the grammar to force apps into +imperative jobs would move 23 pieces of scheduling logic into three codebases, +untested and unauditable, and would be a straight ADR-031 violation. The fix is +to make the declarative dialect say what its users are already trying to say. + +**The engine internals are imperative, necessarily, and stay so.** The parser, +the AST and the evaluator are PHP in `lib/Service/Notification/`. There is no +declarative way to define a grammar's own semantics; this is the engine, not a +consumer of it. What ADR-031 buys here is the *shape* of the imperative code: +one definition of the grammar with two consumers (Decision 1), rather than the +current two definitions with none in common. + +**The gate is where declarative meets mechanical.** Because the surface is +declarative and machine-readable, a checker can read every rule in the fleet +without executing anything (Decision 5). That is a property an imperative +implementation would not have — you cannot grep 23 hand-written `TimedJob`s for +"filters on a field that does not exist". + +No lifecycle, aggregation, derived-field, relation or widget annotation is +touched. No schema is introduced or modified, so no Seed Data section applies +(ADR-001). + +## Goals / Non-Goals + +**Goals** + +- One grammar definition; the validator and the evaluator cannot disagree about + what is executable. +- All 22 currently-dead decidesk and shillinq rules become executable **without + editing them**. +- The 49 filter entries that work today (28 scalar, 21 canonical-operator) + behave byte-for-byte identically. +- The normalised filter is an artefact PERF-3 can compile, not an obstacle. + +**Non-Goals** + +- Editing the 24 dead entries. They are follow-up changes in decidesk, shillinq + and openconnector; this change makes 22 of them correct as written and tells + the fourth what to write. +- Unifying the `created` and `scheduled` evaluators. Decision 4. +- Implementing the PERF-3 SQL pushdown. Decision 6 makes it cheaper; it does + not do it. +- Making a malformed notification annotation fail a schema save or a register + import. Decision 5. +- Any new notification channel, recipient form, throttle or dedupe behaviour. + +## Grammar specification + +The complete normative grammar is in +`specs/notificatie-engine/spec.md`. What follows is the implementation-facing +restatement — the exact surface the parser accepts, in one place, so the gate +author and the PHP author are reading the same text. + +### Entry forms + +A `filter` is a map. Each entry is one of four forms: + +| Form | Written as | Means | +|---|---|---| +| Scalar | `{"status": "open"}` | `status === "open"`, strict | +| Bare list | `{"status": ["open","pending"]}` | `in` over the list | +| Operator object | `{"status": {"operator": "in", "values": [...]}}` | the named operator | +| Combinator | `{"all": [clause, …]}` / `{"any": [clause, …]}` | conjunction / disjunction | + +Top-level entries are ANDed, unchanged from today. An empty filter matches. +`all` and `any` are reserved top-level keys; the census confirms no fleet +schema filters a field with either name, so reserving them breaks nothing that +exists. + +### Clause form (inside a combinator) + +`{"field": "", "operator": "", "value": v}` — or `"values": [...]` +for the membership operators. A clause MAY itself be `{"all": [...]}` or +`{"any": [...]}`. Depth is bounded at 5; deeper is a validation error, not a +stack overflow. + +This is deliberately the same object shape `createdFilterMatches()` already +reads (`AnnotationNotificationDispatcher.php:1686-1692`). See Decision 4. + +### Operators + +| Operator | Operand | Semantics | Status | +|---|---|---|---| +| `equals` | `value` | strict `===` | existing | +| `notEquals` | `value` | strict `!==`; missing/null satisfies it for any non-null `value` | existing | +| `withinNext` | `value`: ISO-8601 duration | field date in `(now, now + d]` | existing | +| `olderThan` | `value`: ISO-8601 duration | field date `< now - d` | existing | +| `in` | `values`: non-empty list | strict membership; array field → non-empty intersection | **new** | +| `notIn` | `values`: non-empty list | strict negation of `in`; missing/null satisfies it | **new** | +| `before` | `value`: reference instant | field date `<` instant | **new** | +| `after` | `value`: reference instant | field date `>` instant | **new** | + +`in`/`notIn` use the **strict** comparison of the scheduled path, not the +string-coercing comparison of the created path. Decision 4 explains why the two +must not be quietly merged. + +### Reference instant (`before` / `after`) + +Three spellings, resolved against the scan's single `$now`: + +- `"now"` → `$now`. +- An ISO-8601 date or date-time (`"2026-01-01"`, `"2026-01-01T00:00:00Z"`) → + that instant, absolutely. +- A **signed** ISO-8601 duration → `"P7D"` is `$now + 7 days`; `"-P7D"` is + `$now - 7 days`. `DateInterval::__construct()` rejects a leading `-`, so the + parser strips it and sets `invert`. + +The sign is mandatory for the past direction and there is no unsigned "past" +spelling. This is the one place the grammar could have been ambiguous — +`{"dueDate": {"operator": "before", "value": "P7D"}}` reads equally naturally as +"before a week from now" and "before a week ago" — and an ambiguous date +operator in a reminder engine is how you send a thousand wrong emails. Making +the sign explicit removes the reading entirely. + +Anything else fails closed: the entry does not match, and nothing matches, which +for a `before "soon"` typo means silence rather than a mass dispatch. + +### Compatibility of the three broken dialects + +| Dialect | App | Rules | Entries | Example | After this change | Edit needed | +|---|---|---|---|---|---|---| +| Bare list = membership | decidesk | 18 | 18 | `{"lifecycle": ["open","in-uitvoering"]}` (`45-toezeggingen-ingekomen-stukken.json:216`) | **Executable.** Parsed as `in` over the list. | **None** | +| `all` + `notIn`/`before`/`equals` | shillinq | 4 | 4 (7 clauses) | `{"all":[{"field":"state","operator":"notIn","values":["paid","written-off","voided"]},{"field":"dueDate","operator":"before","value":"now"}]}` (`bookkeeping-accounts-payable-core.json:840`) | **Executable.** `all` is a combinator; `notIn` and `before "now"` are in the grammar. | **None** | +| `op` key + `lt` | openconnector | 1 | 2 | `{"isEnabled":{"op":"equals","value":true},"nextRun":{"op":"lt","value":"now"}}` (`openconnector_register.json:1154`) | **Still not executable** — and now a validation ERROR naming `operator`, instead of a silent accept. | **Yes**: `op`→`operator`, `lt`→`before` | +| Canonical operator object | fleet-wide | — | 21 | `{"dueDate":{"operator":"withinNext","value":"PT24H"}}` | Unchanged, byte-for-byte. | None | +| Scalar | fleet-wide | — | 28 | `{"lifecycleState": "overdue"}` (`shillinq-notifications.json:17`) | Unchanged, byte-for-byte. | None | + +22 of the 23 dead rules come back to life on deploy, with no edit in any app. +That outcome is the reason bare-list and `all` were chosen over cleaner +alternatives (Decision 2). + +The shillinq rules are worth one extra check, because the sibling rewrite note +at `shillinq-notifications.json:7` says the ARInvoice version of this filter +was *also* wrong about its field name (`state` does not exist on `ARInvoice`; +the field is `lifecycleState`). The APTransaction rule is not affected by that: +`bookkeeping-accounts-payable-core.json` does declare `APTransaction.state` as +an enum containing `paid`, `written-off` and `voided`. So the four shillinq +rules are dead **only** because of the grammar, and fixing the grammar is +sufficient for them. + +## Decisions + +### Decision 1 — One parser, two consumers (the root-cause fix) + +Introduce `lib/Service/Notification/ScheduledFilterParser.php`, which turns a +raw `filter` array into either a normalised AST or a list of structured errors. +`ScheduledFilterEvaluator` evaluates the AST. +`NotificationAnnotationValidator::validateScheduledFilterEntry()` +(`:1018-1102`) reports the parser's errors. + +Alternatives considered: + +- *Just add the operators to both files.* This is what the last change did: + `validateScheduledFilterEntry()` and `entryMatches()` were written to the same + four operators independently, and they still drifted — the validator's + accept-set is `{scalars} ∪ {arrays without an "operator" key} ∪ {four valid + operator objects}` while the evaluator's execute-set is `{scalars} ∪ {four + valid operator objects}`. Adding four more operators to two hand-kept lists + doubles the surface on which the same drift can recur. +- *Generate one from the other.* No mechanism in this codebase does that, and + inventing one is more machinery than the problem needs. + +The AST also gives a place to put the depth bound and the reserved-key handling +once, rather than in both files. + +### Decision 2 — Adopt the invented dialects rather than a cleaner one + +`in`, `notIn`, `before`, `after`, bare-list and `all`/`any` are not chosen on +aesthetics. They are chosen because three teams, independently and without +coordination, reached for exactly these forms, and because adopting them makes +22 of the 23 dead rules correct with **zero edits in three other repositories**. + +The alternative — define a cleaner grammar (say, `{"operator": "oneOf", +"values": [...]}` and an explicit `{"operator": "and", "clauses": [...]}`) — +would require 22 follow-up edits, each a PR in another repo, each a chance to +get it wrong again, in exchange for a marginally tidier keyword. The evidence +that these forms are the natural ones is the defect itself. + +`op` is the one invented spelling **not** adopted. Two rules use it against +21 that use `operator`; accepting both would create a second permanent spelling +for the sake of one rule, and permanent synonyms in a dialect are how dialects +fragment. Instead the validator names `operator` in the error message so the fix +is obvious rather than guessable. + +### Decision 3 — Bare list means `in`, and this is a knowing (empty) break + +Today a list spec reaches `$actual === $spec` (`ScheduledFilterEvaluator.php:118`) +and therefore means *"the field holds exactly this array"* — meaningful for a +multi-select field. After this change it means membership. That is a semantic +break in the letter of the contract. + +Measured blast radius: zero. The census classifies every scheduled filter entry +in every `lib/Settings/**/*.json` in the workspace: 28 scalar, 21 canonical +operator objects, 18 bare lists, 4 combinators, 2 `op`-key. All 18 bare lists +are on scalar enum fields (`lifecycle`, `status`) in decidesk, every one of them +plainly intended as membership, and none of them can match today. No rule +anywhere relies on array-identity. + +The array-valued-field case does not disappear, it gets a defined answer: +when the object's field is itself a list, `in` matches on non-empty +intersection. That is the useful reading for a multi-select field and it is +specified rather than emergent. + +### Decision 4 — Converge the vocabulary, not the comparison; do not merge the two evaluators + +The `created` and `scheduled` filters diverging is itself a defect risk — the +brief is right about that, and the openconnector census result proves it: +`call_log.call-failed` (`openconnector_register.json:1940`) and +`job_log.job-error` (`:2114`) are `created` rules that pass a **map-shaped** +filter to a function that reads `$filter['field']`, so they return false at +`:1687-1690` and have never fired either. Same author, same misconception, other +path. + +So this change converges what can be converged safely: + +- **The clause shape.** A scheduled combinator clause is + `{field, operator, value|values}` — deliberately the created path's object, + so a developer who has written one has written the other. +- **The operator names.** `in`/`notIn` mean the same thing in both, and now + exist in both. +- **The detection.** The gate (Decision 5) checks both trigger paths, so the two + dead `created` rules are caught by the same net. + +And it explicitly does **not** converge the comparison semantics. +`createdFilterMatches()` casts both sides to string (`:1694-1697`, `:1723`), so +`{"statusCode": {"operator":"equals","value":400}}` matches the integer `400` +and the string `"400"` alike, and `{"isEnabled": true}` matches `1`, `"1"` and +`true`. The scheduled path is strict `===`. Making the created path strict +would silently stop matching wherever a JSON number meets a string column — a +regression with no failing test to announce it, in rules that fire on writes. +Making the scheduled path coercive would change the meaning of the 28 scalar +entries that work today, which the Goals forbid. + +Merging the two evaluators therefore means picking one comparison and breaking +the other path's live rules. The correct sequencing is: fix the grammar and the +detection now, then converge the comparison as its own change with its own +before/after census of every `created` rule in the fleet. This design records +that as required follow-up rather than pretending the divergence is fine. + +### Decision 5 — Detection belongs in the Hydra gate, with unit tests as the engine's own proof + +Both, with a clear division of labour, and the gate is the part that blocks. + +**OpenRegister unit tests** (`tests/Unit/Service/Notification/ScheduledFilterEvaluatorTest.php`, +`NotificationAnnotationValidatorTest.php`, plus a new parser test) prove the +engine: every operator arm, every fail-closed path, every rejection. They cannot +do the job on their own for one structural reason — the 23 dead rules live in +`decidesk/lib/Settings/register.d/*.json`, +`shillinq/lib/Settings/register.d/*.json` and +`openconnector/lib/Settings/openconnector_register.json`. OpenRegister's test +suite never reads those files and never will; they are other repositories. + +**Hydra gate-18** is where the fleet-wide check goes. Three reasons, in order of +weight: + +1. **It is the only place that can block a merge.** OpenRegister's validator + cannot: `SchemaMapper.php:1364-1381` logs its findings and returns. Even a + perfectly strict validator would have let all 24 entries install. The gate + fails a PR. +2. **It already reads exactly these files.** + `.github/hydra-gates/scripts/lib/check_notification_dialect.py` JSON-parses + `lib/Settings/*register*.json` and `register.d/*.json`, iterates every + `x-openregister-notifications` block and scans each rule + (`_iter_notification_blocks`, `_scan_rule`), and is wired at + `.github/hydra-gates/scripts/run-hydra-gates.sh:3926-4031` with a + crashed-checker guard and an empty-scope guard already in place. The new + check is a function in a file that already has the data in hand. +3. **It runs in the repo where the rule is authored,** at the moment it is + authored, which is the only moment the author is available to fix it. + +The gate's check (c): for every `scheduled` trigger filter entry, classify as +executable or not; report `: rule= field= reason=`; +non-executable is a **hard fail**, matching check (a)'s posture rather than +check (b)'s advisory warning. It also classifies `created` trigger filters +against `createdFilterMatches()`'s shape, which catches openconnector's other +two dead rules. + +Alternatives considered: + +- *An `occ` command operators run after import.* Detects, but after the fact and + only if someone runs it. The 24 entries survived months of imports. +- *Make the schema save fail.* Rejected on the same grounds the existing comment + gives: on config import one malformed optional annotation would abort the + whole register and every schema referencing it, and zero objects would land. + Turning a silent dead rule into a total import failure is a worse trade. +- *An OpenRegister test that globs `../*/lib/Settings/`.* Depends on a workspace + layout that exists on one developer's machine and in no CI job. + +Drift between the PHP grammar and the gate's Python is the obvious hazard and is +handled by naming a single source of truth: the operator table in +`openspec/specs/notificatie-engine/spec.md`, which the spec now states any +external checker must derive from. Task 5.3 pins the gate's copy against a +fixture set generated from the PHP constant. + +### Decision 6 — The AST is what makes PERF-3 cheaper, not harder + +A richer grammar sounds like it should make SQL pushdown harder. It does the +opposite here, for a specific reason: every operator in the table above is a +**single-column predicate**, and the two combinators are **conjunction and +disjunction**. That is a `WHERE` tree. + +| Grammar node | SQL | +|---|---| +| `equals` / `notEquals` | `col = ?` / `col <> ?` | +| `in` / `notIn` | `col IN (…)` / `col NOT IN (…)` | +| `before` / `after` | `col < ?` / `col > ?` (instant resolved in PHP against the scan's `now`) | +| `withinNext` / `olderThan` | `col > ? AND col <= ?` / `col < ?` | +| `all` / `any` | `AND` / `OR` | + +What blocks pushdown today is not expressiveness, it is that the filter is +consumed as raw JSON inside `entryMatches()` — there is nothing a query builder +can be handed. The AST is that thing. `MagicMapper`'s `_filter` is the target +(`ScheduledNotificationJob.php:64-67`), and a compiler from AST to `_filter` is a +strictly separate change with a clean input. + +Two constraints this design imposes now so PERF-3 does not have to relitigate +them: + +- **The `now` is resolved before compilation.** `withinNext`, `olderThan`, + `before` and `after` all resolve to absolute instants against the scan's + single `$now`, so a compiled predicate is a plain comparison with a bound + parameter and the scan stays consistent across a paged sweep. +- **Partial pushdown must agree with full in-memory evaluation.** The spec + requires it, and the AST makes it checkable: push what compiles, evaluate the + rest in memory over the reduced set, and the result is identical because + conjunction is associative and the leaves are pure. + +The `TODO(PERF-3)` comment and the rotating-window warning at `:347` are updated +to say the AST is the input, so the next person to open that file finds the plan +where the plan already is. + +## Risks / Trade-offs + +- **Bare-list re-meaning silently changes an array-identity filter** → Census + shows zero such filters exist (Decision 3). The gate's check (c) reports the + bare-list form so a future array-identity author is told at PR time that the + form means membership. +- **22 rules go from dispatching nothing to dispatching** → This is the point, + but the first scan after deploy will find a backlog: every decidesk + `Toezegging` past its deadline, every overdue shillinq obligation, all at + once. Mitigated by the existing per-object dedupe + (`NotificationDedupeStateMapper`, spec requirement "Scheduled rules MUST + deduplicate dispatch per object") which caps it at one notification per object + per fingerprint rather than one per scan — but it is still a burst, and it + lands in three apps that have never seen these notifications. Task 6.2 stages + the rollout: land the grammar, then let each app enable its rules + deliberately. +- **`all`/`any` reserved as top-level keys** → Zero fleet collisions today + (verified); a schema that later adds a field named `all` filters it via a + clause. The validator names the reserved key in its error rather than + producing a confusing type complaint. +- **PHP and Python grammars drift again** → Decision 5's single source of truth + plus the fixture-pinning task. This is a real ongoing cost and worth naming as + one: two implementations of one grammar is a compromise forced by the gate + runner being Python and the engine being PHP. +- **`created` and `scheduled` still compare differently** → Recorded, scoped and + deferred with reasons (Decision 4), not left implicit. The gate covers both + paths so the *shape* class of defect is closed on both even while the + comparison semantics diverge. +- **The spec loses its "HTTP 422" language** → Deliberate. It described + behaviour that has never existed; leaving it in leaves a second, quieter + spec/code divergence next to the one being fixed. + +## Migration Plan + +No database migration, no schema change, no configuration change. + +1. Land the parser, evaluator and validator together. They are one grammar; a + deploy with only one of them re-opens the drift. +2. Deploy OpenRegister. On the next `ScheduledNotificationJob` tick the 22 + decidesk/shillinq rules begin evaluating. Nothing in those apps changes. +3. Land the gate-18 extension in `ConductionNL/.github`. From that point a new + dead filter fails its own PR. +4. Follow-ups in the consuming repos: openconnector fixes `job.job-overdue` + (`op`→`operator`, `lt`→`before`) and its two `created` rules; decidesk and + shillinq need nothing. + +**Rollback.** Revert the OpenRegister commit. The grammar is additive to the +evaluator (new `case` arms and two new entry forms), so reverting returns the +four-operator behaviour and the 24 entries return to being inert — the state +they have been in all along. No data is written by this change, so there is +nothing to unwind. The one asymmetry worth stating: rules that fired between +deploy and rollback stay fired, and their dedupe rows stay written, so a +re-deploy will not re-notify for an unchanged object. + +## Open Questions + +- Should the gate read the operator list from a small machine-readable contract + shipped by OpenRegister (e.g. under `.github/hydra-gates/contracts/`) instead + of holding its own pinned copy? Provisionally: pinned copy plus a fixture + drift test, because the gate must run against a repo checkout that may not + contain OpenRegister at all. Deferrable — it changes neither the grammar nor + the tasks. +- Is a nesting bound of 5 right? Provisionally yes; no fleet filter nests at + all, and the number only needs to be finite and stated. diff --git a/openspec/changes/notification-scheduled-filter-grammar/proposal.md b/openspec/changes/notification-scheduled-filter-grammar/proposal.md new file mode 100644 index 0000000000..0dfba714da --- /dev/null +++ b/openspec/changes/notification-scheduled-filter-grammar/proposal.md @@ -0,0 +1,183 @@ +--- +kind: code +--- + +# Proposal: notification-scheduled-filter-grammar + +## Summary + +Give the `scheduled` notification trigger's `filter` **one** grammar, defined +once and consumed by both the validator and the evaluator: add `in` / `notIn`, +`before` / `after`, a bare list as shorthand for `in`, and the `all` / `any` +combinators — and close the validator hole that lets a filter shape the +evaluator cannot execute be saved without a word. + +## Why + +`ScheduledFilterEvaluator` (`lib/Service/Notification/ScheduledFilterEvaluator.php`) +accepts a flat `field => spec` map, entries ANDed, where `spec` is a scalar +(strict `===`, line 118) or an operator object with exactly four operators — +`equals`, `notEquals`, `withinNext`, `olderThan` (lines 124-161). Anything else +hits the `default:` arm and fails closed (line 162-167). + +`NotificationAnnotationValidator::validateScheduledFilterEntry()` is supposed to +catch that at save time. Its first statement is the defect: + +```php +// lib/Service/Notification/NotificationAnnotationValidator.php:1018-1021 +private function validateScheduledFilterEntry(string $ruleKey, string $field, $spec): array { + // Scalar shortcut: always accepted (legacy v1 strict equality). + if (is_array($spec) === false || array_key_exists('operator', $spec) === false) { + return []; + } +``` + +The branch is named for scalars but it is reached by **any** value that is not +an operator object — including every array. So a list, a combinator, or an +operator object that spells its key `op` instead of `operator` is accepted as +valid, and then at run time line 117-118 of the evaluator compares a scalar +field against an array: `$actual === $spec` is false forever. The rule is +syntactically valid, semantically dead, and completely silent — no warning at +save, only a debug line at scan time, and only for the operator arms. + +A fleet census of every `x-openregister-notifications` block under +`lib/Settings/` in the workspace (ran 2026-08-22) counts **24 filter entries +across 23 rules in 3 apps that the evaluator structurally cannot execute**, +against 49 entries (28 scalar, 21 canonical-operator) that work today. Issue +ConductionNL/openregister#2787. Three teams independently invented three +dialects, none of which the engine knows: + +- **decidesk — 18 rules, 18 entries.** A bare list meaning set-membership: + `{"lifecycle": ["open","in-uitvoering"]}` + (`decidesk/lib/Settings/register.d/45-toezeggingen-ingekomen-stukken.json:216` + and 17 siblings across fragments 45/47/50/52/53/56/59/60/62). +- **shillinq — 4 rules, 4 entries, 7 clauses.** A combinator plus two unknown + operators: `{"all":[{"field":"state","operator":"notIn","values":["paid","written-off","voided"]},{"field":"dueDate","operator":"before","value":"now"}]}` + (`shillinq/lib/Settings/register.d/bookkeeping-accounts-payable-core.json:840`, + plus `contract-lifecycle-management.json:385`, `:433`, `:703`). +- **openconnector — 1 rule, 2 entries.** The wrong key and an unknown operator: + `{"isEnabled":{"op":"equals","value":true},"nextRun":{"op":"lt","value":"now"}}` + (`openconnector/lib/Settings/openconnector_register.json:1154`, rule + `job.job-overdue`). + +The shillinq case proves the hole is already known and still open. +`shillinq/lib/Settings/register.d/shillinq-notifications.json:7` records, in +prose, that this grammar is non-canonical — *"a non-canonical +{all:[{field,operator,…}]} filter grammar with operators (notIn, before) the +canonical scheduled-filter grammar … does not know"* — and that the sibling +ARInvoice rules were rewritten to `{"lifecycleState": "overdue"}` for exactly +that reason (`:17`). The four rules in the other two fragments were never +touched. A note in a description is not a gate. + +Two facts sharpen the fix and are worth stating up front, because both +contradict the obvious reading: + +1. **Tightening the validator alone does not stop anything shipping.** + `SchemaMapper::validateNotificationsAnnotation()` calls the validator and + then deliberately **discards** its errors — + `lib/Db/SchemaMapper.php:1364-1381` logs a warning and returns, with the + comment *"A malformed OPTIONAL notification annotation must NOT block the + schema itself"*. There is no 422 anywhere in that file. Meanwhile + `openspec/specs/notificatie-engine/spec.md:846-853` requires the validator + to *"reject, with HTTP 422"*. The spec and the code have disagreed since the + `notification-engine-scheduled-conditions` change landed, and nobody noticed + because nothing reads the warning. Detection therefore cannot live in the + validator alone. +2. **The same class of defect exists on the `created` path.** + `AnnotationNotificationDispatcher::createdFilterMatches()` + (`lib/Service/Notification/AnnotationNotificationDispatcher.php:1686-1724`) + reads `$filter['field']` and returns false when it is empty (`:1687-1690`). + openconnector's two `created` rules `call_log.call-failed` + (`openconnector_register.json:1940`) and `job_log.job-error` (`:2114`) pass a + map-shaped filter with the `op` key, so they too match nothing, ever. They + are out of scope for this change's evaluator but they belong in the same + detection net. + +## What Changes + +- **Extend the scheduled-filter grammar** with the operators three teams + independently reached for: `in` / `notIn` (taking `values`), and `before` / + `after` (taking a reference instant: `"now"`, an ISO-8601 date/date-time, or + a signed ISO-8601 duration relative to now). Fail-closed semantics for + unparsable dates and unknown operators are unchanged. +- **Bare list as shorthand for `in`.** `{"lifecycle": ["open","x"]}` means + `{"lifecycle": {"operator": "in", "values": ["open","x"]}}`. **BREAKING** in + the letter of the contract: today a list spec means strict `===` equality + against an array-valued field. Measured blast radius: zero. All 18 bare-list + entries in the fleet sit on scalar enum fields (`lifecycle`, `status`), and + no rule in the census relies on array-identity equality. +- **`all` / `any` combinators** as reserved top-level keys of `filter`, taking a + list of clause objects in the `{field, operator, value|values}` shape — + the same shape `createdFilterMatches()` already parses. **BREAKING** in the + letter of the contract: a schema field literally named `all` or `any` can no + longer be filtered by a top-level entry. Measured blast radius: zero — the + census finds `all`/`any` used only as shillinq's combinators, never as a + field name. +- **One grammar definition, two consumers.** A new `ScheduledFilterParser` + produces a normalised filter AST; `ScheduledFilterEvaluator` evaluates that + AST instead of re-walking raw JSON, and `NotificationAnnotationValidator` + reports that same parser's errors. A shape the validator accepts but the + evaluator cannot execute becomes structurally impossible rather than merely + discouraged. +- **Tighten `validateScheduledFilterEntry()`.** The "scalar shortcut" branch + narrows to actual scalars. An array that is neither a bare list, nor a + recognised operator object, nor a combinator is an ERROR. An operator object + spelled `op` produces an error that names `operator` explicitly, rather than + being silently accepted. +- **Detection that actually blocks a merge:** extend Hydra gate-18 + (`check_notification_dialect.py`) with a scheduled-filter-shape check, because + the 23 rules live in repos OpenRegister's own test suite never reads and the + OR-side validator's findings are discarded before they reach anyone. +- **PERF-3:** the AST is the enabling artefact for the SQL pushdown that + `lib/BackgroundJob/ScheduledNotificationJob.php:64-67` defers. The richer + grammar is *more* translatable to a `WHERE` tree, not less. + +## Capabilities + +### New Capabilities + +None. This change extends an existing declarative surface. + +### Modified Capabilities + +- `notificatie-engine`: the scheduled-trigger filter grammar gains membership, + date-comparison and boolean-combinator forms; the save-time validation + requirement is restated so that a shape the evaluator cannot execute is an + error rather than an accepted no-op, and the requirement's enforcement point + is stated explicitly (the validator reports; the fleet gate blocks). + +## Impact + +**OpenRegister code** + +- `lib/Service/Notification/ScheduledFilterEvaluator.php` — evaluates an AST; + new operator arms; `parseDate`/`parseDuration` retained and reused. +- `lib/Service/Notification/ScheduledFilterParser.php` — new; the single + definition of the grammar. +- `lib/Service/Notification/NotificationAnnotationValidator.php:1018-1102` — + `validateScheduledFilterEntry()` delegates to the parser. +- `lib/BackgroundJob/ScheduledNotificationJob.php` — unchanged behaviour; + the `TODO(PERF-3)` note (`:64-67`, `:347`) is updated to name the AST as the + pushdown input. +- `openspec/specs/notificatie-engine/spec.md` — the grammar requirement. + +**Fleet (follow-up changes in other repos, NOT specified here)** + +- decidesk: 18 rules become executable **with no edit at all**. +- shillinq: 4 rules become executable **with no edit at all**. +- openconnector: 1 scheduled rule (`job.job-overdue`, 2 entries) still needs an + edit — `op` → `operator`, `lt` → `before`. Its 2 `created`-trigger rules need + the same key fix. + +**Tooling** + +- `.github` (`conduction/hydra-gates`): gate-18 check (c). Not editable from + this repo; tracked as a follow-up with the grammar in + `openspec/specs/notificatie-engine/spec.md` as its source of truth. + +**Not changed** + +- The `created`-trigger filter's comparison semantics + (`AnnotationNotificationDispatcher.php:1694-1723`, string-coerced) stay as + they are. See design — converging them here would silently un-match existing + rules. diff --git a/openspec/changes/notification-scheduled-filter-grammar/specs/notificatie-engine/spec.md b/openspec/changes/notification-scheduled-filter-grammar/specs/notificatie-engine/spec.md new file mode 100644 index 0000000000..878d9b6470 --- /dev/null +++ b/openspec/changes/notification-scheduled-filter-grammar/specs/notificatie-engine/spec.md @@ -0,0 +1,320 @@ +## MODIFIED Requirements + +### Requirement: Scheduled trigger filters MUST support relative-date and inequality operators + +A `scheduled` trigger's `filter` MUST be supported as a flat map of object-data field names to conditions, ANDed together. +Each condition MUST be accepted in four forms: + +- **Scalar (v1, unchanged):** `{"status": "open"}` — strict equality + against the object's field value, byte-for-byte the existing + behaviour. +- **Bare list (v1.2):** `{"status": ["open","pending"]}` — shorthand, + exactly equivalent to `{"status": {"operator": "in", "values": + ["open","pending"]}}`. +- **Operator object (v1.1, extended in v1.2):** `{"": {"operator": + "", "value": }}` — or `"values": [, …]` for the + membership operators — with the operators: + - `equals` — field value equals `value` (same comparison semantics as + the scalar form). + - `notEquals` — field value does not equal `value`. A missing/null + field value satisfies `notEquals` for any non-null `value`. + - `withinNext` — the field value, parsed as a date or date-time, lies + in the half-open window `(now, now + value]`, where `value` is an + ISO-8601 duration (e.g. `PT24H`, `P7D`) and `now` is the evaluating + scan's clock. + - `olderThan` — the field value, parsed as a date or date-time, lies + before `now - value`, `value` an ISO-8601 duration. + - `in` — the field value is a member of the `values` list. + - `notIn` — the field value is not a member of the `values` list. A + missing/null field value satisfies `notIn` for any non-empty list. + - `before` — the field value, parsed as a date or date-time, is + strictly earlier than the condition's reference instant. + - `after` — the field value, parsed as a date or date-time, is + strictly later than the condition's reference instant. +- **Combinator (v1.2):** the reserved top-level keys `all` and `any`, + each taking a non-empty list of clause objects of the shape + `{"field": "", "operator": "", "value"|"values": …}`. `all` + matches when every clause matches; `any` matches when at least one + clause matches. A clause MAY itself be `{"all": […]}` or `{"any": + […]}`; nesting MUST be bounded and a filter nested deeper than the + bound MUST be reported as invalid rather than evaluated. + +Comparison semantics for `equals`, `notEquals`, `in` and `notIn` MUST be +type-strict, identical to the v1 scalar form. The type-coercing +comparison used by `created`-trigger filters MUST NOT be applied to +scheduled filters. + +Membership over an array-valued field MUST be defined: when the object's +field value is itself a list, `in` matches when the intersection with +`values` is non-empty, and `notIn` matches when it is empty. + +The reference instant for `before` and `after` MUST be accepted in three +spellings, resolved against the scan's `now`: + +- the literal string `now`; +- an ISO-8601 date or date-time (e.g. `2026-01-01`, + `2026-01-01T00:00:00Z`), taken absolutely; +- a signed ISO-8601 duration relative to `now` — `P7D` means `now + 7 + days`, `-P7D` means `now - 7 days`. + +Relative-date operators MUST fail closed: when the field value is +missing, null, or not parseable as a date/date-time, the condition does +NOT match (and the engine logs at debug level, not warning — unfilled +date fields are normal data). A reference instant that cannot be +resolved MUST likewise fail closed. An unknown operator MUST fail +closed. All top-level filter entries MUST hold for the object to match +(AND semantics, unchanged); an empty filter map MUST match. + +An empty `all` list MUST match (vacuously true, consistent with the +empty filter map). An empty `any` list MUST NOT match. + +`all` and `any` are RESERVED top-level keys. A schema field named `all` +or `any` MUST be filtered through a combinator clause +(`{"all":[{"field":"all", …}]}`) rather than a top-level entry. + +#### Scenario: Deadline window matched with `withinNext` +- GIVEN a `scheduled` rule with filter `{"dueDate": {"operator": "withinNext", "value": "PT24H"}}` +- AND an object whose `dueDate` is 6 hours after the scan's `now` +- WHEN the scheduled job evaluates the filter +- THEN the object matches and the rule dispatches for it + +#### Scenario: Object outside the `withinNext` window does not match +- GIVEN the same rule +- AND an object whose `dueDate` is 3 days after `now`, and another whose `dueDate` is 1 hour before `now` +- WHEN the scheduled job evaluates the filter +- THEN neither object matches (the window is future-only and bounded by the duration) + +#### Scenario: `olderThan` selects stale objects +- GIVEN a `scheduled` rule with filter `{"lastSyncedAt": {"operator": "olderThan", "value": "P7D"}}` +- AND an object whose `lastSyncedAt` is 10 days before `now` +- WHEN the scheduled job evaluates the filter +- THEN the object matches + +#### Scenario: `notEquals` excludes terminal states and combines with AND semantics +- GIVEN a `scheduled` rule with filter `{"dueDate": {"operator": "withinNext", "value": "PT24H"}, "status": {"operator": "notEquals", "value": "done"}}` +- AND object A with `dueDate` in 6 hours and `status: "open"`, and object B with `dueDate` in 6 hours and `status: "done"` +- WHEN the scheduled job evaluates the filter +- THEN object A matches and object B does not + +#### Scenario: Unparsable date fails closed +- GIVEN a `scheduled` rule with a `withinNext` condition on `dueDate` +- AND an object whose `dueDate` value is the string `"soon"` +- WHEN the scheduled job evaluates the filter +- THEN the object does NOT match +- AND no warning-level log entry is produced for it + +#### Scenario: Scalar filters keep v1 equality semantics +- GIVEN a `scheduled` rule with filter `{"status": "open"}` (scalar form) +- WHEN the scheduled job evaluates the filter +- THEN matching is strict equality exactly as before this change, with no operator parsing applied + +#### Scenario: Bare list means set membership +- GIVEN a `scheduled` rule with filter `{"lifecycle": ["open","in-uitvoering"]}` +- AND object A with `lifecycle: "open"`, object B with `lifecycle: "afgerond"`, and object C with no `lifecycle` value +- WHEN the scheduled job evaluates the filter +- THEN object A matches and objects B and C do not + +#### Scenario: `notIn` excludes a terminal set and admits missing values +- GIVEN a `scheduled` rule with filter `{"state": {"operator": "notIn", "values": ["paid","written-off","voided"]}}` +- AND object A with `state: "received"`, object B with `state: "paid"`, and object C with no `state` value +- WHEN the scheduled job evaluates the filter +- THEN objects A and C match and object B does not + +#### Scenario: Membership over an array-valued field uses intersection +- GIVEN a `scheduled` rule with filter `{"tags": ["urgent","escalated"]}` +- AND object A with `tags: ["routine","urgent"]` and object B with `tags: ["routine"]` +- WHEN the scheduled job evaluates the filter +- THEN object A matches and object B does not + +#### Scenario: `before "now"` selects past-due objects +- GIVEN a `scheduled` rule with filter `{"dueDate": {"operator": "before", "value": "now"}}` +- AND object A whose `dueDate` is 2 days before the scan's `now` and object B whose `dueDate` is 2 days after it +- WHEN the scheduled job evaluates the filter +- THEN object A matches and object B does not + +#### Scenario: `after` with a signed duration reference instant +- GIVEN a `scheduled` rule with filter `{"reviewDate": {"operator": "after", "value": "-P30D"}}` +- AND object A whose `reviewDate` is 10 days before `now` and object B whose `reviewDate` is 60 days before `now` +- WHEN the scheduled job evaluates the filter +- THEN object A matches (it is later than `now - 30 days`) and object B does not + +#### Scenario: Unresolvable reference instant fails closed +- GIVEN a `scheduled` rule with filter `{"dueDate": {"operator": "before", "value": "soon"}}` +- WHEN the scheduled job evaluates the filter against any object +- THEN no object matches +- AND the rule dispatches for nobody + +#### Scenario: `all` combinator ANDs its clauses +- GIVEN a `scheduled` rule with filter `{"all": [{"field": "state", "operator": "notIn", "values": ["paid","voided"]}, {"field": "dueDate", "operator": "before", "value": "now"}]}` +- AND object A with `state: "received"` and a past `dueDate`, object B with `state: "paid"` and a past `dueDate`, and object C with `state: "received"` and a future `dueDate` +- WHEN the scheduled job evaluates the filter +- THEN only object A matches + +#### Scenario: `any` combinator ORs its clauses +- GIVEN a `scheduled` rule with filter `{"any": [{"field": "status", "operator": "equals", "value": "escalated"}, {"field": "dueDate", "operator": "before", "value": "now"}]}` +- AND object A with `status: "escalated"` and a future `dueDate`, object B with `status: "open"` and a past `dueDate`, and object C with `status: "open"` and a future `dueDate` +- WHEN the scheduled job evaluates the filter +- THEN objects A and B match and object C does not + +#### Scenario: Empty combinator lists resolve in opposite directions +- GIVEN a `scheduled` rule with filter `{"all": []}` and another with filter `{"any": []}` +- WHEN the scheduled job evaluates each filter against any object +- THEN every object matches the `all` rule +- AND no object matches the `any` rule + +#### Scenario: A combinator entry ANDs with its sibling top-level entries +- GIVEN a `scheduled` rule with filter `{"register": "contracts", "any": [{"field": "status", "operator": "equals", "value": "expired"}, {"field": "renewalDecisionDate", "operator": "before", "value": "now"}]}` +- AND object A with `register: "contracts"` and `status: "expired"`, and object B with `register: "invoices"` and `status: "expired"` +- WHEN the scheduled job evaluates the filter +- THEN object A matches and object B does not + +### Requirement: Scheduled filter operator grammar MUST be validated when the schema is saved + +The notification-annotation validator MUST report a structured error for +any `scheduled` trigger filter entry whose shape the evaluator cannot +execute. Specifically it MUST report: + +- an operator object with an unknown `operator`; +- an operator object with a missing `value` (or missing `values` for the + membership operators); +- a `value` that is not a valid ISO-8601 duration when the operator is + `withinNext` or `olderThan`; +- a `value` that is not a resolvable reference instant when the operator + is `before` or `after`; +- a `values` key that is not a non-empty list for `in` / `notIn`; +- a combinator whose clause list is not a list, or whose clause is not an + object carrying a `field` key (or a nested combinator), or which nests + deeper than the supported bound; +- **any other non-scalar entry that is neither a bare list, nor a + recognised operator object, nor a recognised combinator.** + +The final bullet is the load-bearing one: an entry MUST NOT be accepted +merely because it lacks an `operator` key. Scalar entries, bare lists, +well-formed operator objects and well-formed combinators MUST be +accepted, and nothing else MUST be. + +An operator object that carries the key `op` instead of `operator` MUST +produce an error whose message names `operator` as the expected key, so +the author is told what to write rather than being left with an accepted +rule that never fires. + +Every structured error MUST identify the rule key, the field (or clause +path), and the offending value, consistent with the existing +throttle-window-grammar requirement. + +The validator's findings MUST be reported by the schema-save path. The +save path MAY continue to treat a malformed OPTIONAL notification +annotation as non-fatal for the schema itself, so that one bad rule +cannot abort a register import; where it does so, the findings MUST +still be surfaced to the operator rather than only written to a log that +nothing reads. + +The set of recognised operators, entry forms and combinators defined by +the preceding requirement is the single source of truth for this +validation. Any external checker that judges the same filters MUST be +derived from it. + +#### Scenario: Unknown operator rejected at save time +- GIVEN a schema whose `scheduled` rule filter contains `{"dueDate": {"operator": "near", "value": "PT24H"}}` +- WHEN the schema is saved +- THEN the validator MUST produce a structured error naming the rule key, the field `dueDate`, and the unknown operator `near` + +#### Scenario: Invalid duration rejected at save time +- GIVEN a schema whose `scheduled` rule filter contains `{"dueDate": {"operator": "withinNext", "value": "24h"}}` +- WHEN the schema is saved +- THEN the validator MUST produce a structured error stating that `withinNext` requires an ISO-8601 duration (e.g. `PT24H`) + +#### Scenario: Well-formed operator filter accepted +- GIVEN a schema whose `scheduled` rule filter combines a scalar entry and `withinNext`/`notEquals` operator objects with valid values +- WHEN the schema is saved +- THEN the validator MUST produce no errors for that filter + +#### Scenario: An unrecognised array entry is no longer silently accepted +- GIVEN a schema whose `scheduled` rule filter contains `{"isEnabled": {"op": "equals", "value": true}}` +- WHEN the schema is saved +- THEN the validator MUST produce a structured error for that entry +- AND the error message MUST name `operator` as the expected key + +#### Scenario: An unknown operator inside a combinator clause is reported +- GIVEN a schema whose `scheduled` rule filter contains `{"all": [{"field": "nextRun", "operator": "lt", "value": "now"}]}` +- WHEN the schema is saved +- THEN the validator MUST produce a structured error identifying the clause and the unknown operator `lt` + +#### Scenario: Bare list and combinator forms are accepted +- GIVEN a schema with one `scheduled` rule filtered by `{"lifecycle": ["open","in-uitvoering"]}` and another filtered by `{"all": [{"field": "state", "operator": "notIn", "values": ["paid"]}, {"field": "dueDate", "operator": "before", "value": "now"}]}` +- WHEN the schema is saved +- THEN the validator MUST produce no errors for either filter + +#### Scenario: A membership operator without a values list is reported +- GIVEN a schema whose `scheduled` rule filter contains `{"status": {"operator": "in", "value": "open"}}` +- WHEN the schema is saved +- THEN the validator MUST produce a structured error stating that `in` requires a non-empty `values` list + +## ADDED Requirements + +### Requirement: A scheduled filter the evaluator cannot execute MUST be mechanically detectable before merge + +Every filter form the evaluator recognises is defined in one place, and +that definition MUST be reachable by a mechanical check that runs on a +change before it merges. The check MUST classify every `scheduled` +trigger filter entry declared in a register annotation as executable or +not executable, and MUST treat "not executable" as a failure rather than +a warning. + +The check MUST be capable of judging register annotations that live +outside the OpenRegister repository, because the declarative +notification surface is consumed by every app in the fleet and a rule +authored in a consuming app is exactly the case that has failed in +practice. + +Detection MUST NOT rely solely on the schema-save validator, because the +save path deliberately does not fail the save on a malformed optional +annotation and therefore cannot prevent a dead rule from being +installed. + +#### Scenario: A newly authored dead filter fails the check +- GIVEN a change that adds a `scheduled` rule whose filter is `{"isEnabled": {"op": "equals", "value": true}}` +- WHEN the mechanical check runs on that change +- THEN the check MUST report the rule key, the file, and the reason the entry is not executable +- AND the check MUST fail + +#### Scenario: The extended grammar forms pass the check +- GIVEN a change that adds a `scheduled` rule filtered by a bare list and another filtered by an `all` combinator over `notIn` and `before` clauses +- WHEN the mechanical check runs on that change +- THEN the check MUST report no findings for those rules + +#### Scenario: Absence of register annotations is not reported as a pass +- GIVEN a change in a repository that declares no register annotations at all +- WHEN the mechanical check runs +- THEN the check MUST report that its scope was empty +- AND MUST NOT report a pass over nothing + +### Requirement: The scheduled filter MUST be expressible as a filter tree that a query planner can consume + +The evaluator MUST derive its decision from a normalised representation +of the filter — a tree of leaf conditions (`field`, operator, operand) +under `all` / `any` nodes — rather than from a direct walk of the raw +annotation. The same normalised representation MUST be the input from +which a future database-side filter is derived, so that in-memory +evaluation and any pushed-down evaluation cannot disagree about what a +rule means. + +Every operator in the grammar MUST be expressible as a single-column +predicate over the object's data: equality, negated equality, set +membership, negated set membership, and a date range bound. `all` and +`any` MUST correspond to conjunction and disjunction of those +predicates. + +Where a filter, or part of one, cannot be pushed down, the engine MUST +fall back to evaluating that part in memory and MUST produce the same +result as evaluating the whole filter in memory. + +#### Scenario: In-memory and pushed-down evaluation agree +- GIVEN a `scheduled` rule whose filter is fully expressible as database predicates +- WHEN the same object set is evaluated once entirely in memory and once with the filter applied by the database +- THEN both evaluations MUST select exactly the same objects + +#### Scenario: A partially pushable filter still evaluates correctly +- GIVEN a `scheduled` rule that combines a pushable membership condition with a condition that cannot be pushed down +- WHEN the scheduled scan runs +- THEN the objects selected MUST be identical to those selected by evaluating the whole filter in memory diff --git a/openspec/changes/notification-scheduled-filter-grammar/tasks.md b/openspec/changes/notification-scheduled-filter-grammar/tasks.md new file mode 100644 index 0000000000..36e70d6c7f --- /dev/null +++ b/openspec/changes/notification-scheduled-filter-grammar/tasks.md @@ -0,0 +1,121 @@ +# Tasks: notification-scheduled-filter-grammar + +## 1. The grammar, defined once + +- [ ] 1.1 `lib/Service/Notification/ScheduledFilterGrammar.php` — the operator + table as constants: `OPERATORS` (`equals`, `notEquals`, `withinNext`, + `olderThan`, `in`, `notIn`, `before`, `after`), `MEMBERSHIP_OPERATORS`, + `DURATION_OPERATORS`, `INSTANT_OPERATORS`, `COMBINATORS` (`all`, `any`), + `MAX_DEPTH = 5`. `@license EUPL-1.2`, `@copyright 2026 Conduction B.V.` +- [ ] 1.2 `lib/Service/Notification/ScheduledFilterParser.php` — raw `filter` + array → normalised AST (leaf nodes `{field, operator, operand}` under + `all`/`any` nodes) OR a list of structured errors in the existing + `{code, ruleKey, field, value, message}` shape. Accepts the four entry + forms; reserves `all`/`any` at the top level; enforces `MAX_DEPTH`. + Resolves nothing time-dependent — the AST holds the raw operand. +- [ ] 1.3 Reference-instant resolution in the parser's operand validation and a + `resolveInstant()` helper on the evaluator: `"now"`, an ISO-8601 + date/date-time, or a **signed** ISO-8601 duration (`P7D` = `now + 7d`, + `-P7D` = `now - 7d`; strip the `-`, set `DateInterval::$invert`). + Unresolvable → parser error at save time, `null` → no match at scan time. + +## 2. Evaluator + +- [ ] 2.1 `ScheduledFilterEvaluator::matches()` parses once via + `ScheduledFilterParser` and walks the AST; `entryMatches()` + (`ScheduledFilterEvaluator.php:113-170`) is replaced by an AST walker. + A filter that fails to parse matches nothing and logs at warning level — + unlike a bad date, an unexecutable rule is not normal data. +- [ ] 2.2 New leaf arms: `in`/`notIn` with strict comparison and non-empty + intersection when the field value is itself a list; `before`/`after` + against the resolved instant. `equals`, `notEquals`, `withinNext`, + `olderThan` keep their current comparisons unchanged, including + `notEquals`'s missing/null rule (`:128-132`). +- [ ] 2.3 Combinator arms: `all` = conjunction (empty list matches), `any` = + disjunction (empty list does NOT match), nested to `MAX_DEPTH`. Top-level + entries stay ANDed, including combinator entries alongside field entries. + +## 3. Validator + +- [ ] 3.1 `NotificationAnnotationValidator::validateScheduledFilterEntry()` + (`:1018-1102`) delegates to `ScheduledFilterParser` and returns its + errors. Delete the "Scalar shortcut: always accepted" branch (`:1019-1021`) + — the accept-set becomes exactly the parser's, which is exactly the + evaluator's. +- [ ] 3.2 The `op`-key diagnostic: an array carrying `op` but not `operator` + produces `notification-scheduled-bad-filter-operator-key` whose message + names `operator` as the expected key and quotes the offending spelling. +- [ ] 3.3 Update the class docblocks that enumerate the old four-operator + grammar: `ScheduledFilterEvaluator.php:5-9` and `:39-55`, + `NotificationAnnotationValidator.php:1004-1017`. + +## 4. PERF-3 seam + +- [ ] 4.1 Update `ScheduledNotificationJob`'s deferred-work notes + (`lib/BackgroundJob/ScheduledNotificationJob.php:64-67` and the rotating + -window warning at `:347`) to name the AST as the pushdown input and + record the two constraints from design Decision 6 (instants resolved + before compilation; partial pushdown must equal full in-memory + evaluation). No behaviour change in this task. + +## 5. Detection + +- [ ] 5.1 `tests/Unit/Service/Notification/ScheduledFilterParserTest.php` — every + accept form and every reject form, including the four fleet dialects + verbatim: decidesk's bare list, shillinq's `all`/`notIn`/`before`, + openconnector's `op`/`lt` (must reject), and a canonical operator object. +- [ ] 5.2 Extend `ScheduledFilterEvaluatorTest.php` (new operators, combinators, + empty-`all`/empty-`any` asymmetry, array-field intersection, fail-closed + instants) and `NotificationAnnotationValidatorTest.php` (the entry that + used to be silently accepted is now an error). +- [ ] 5.3 Gate-18 check (c) in `ConductionNL/.github` + (`hydra-gates/scripts/lib/check_notification_dialect.py`, wired at + `scripts/run-hydra-gates.sh:3926-4031`): classify every `scheduled` filter + entry and every `created` filter as executable or not, hard-fail on not, + empty scope reported as empty. Pin its operator list against a fixture set + generated from `ScheduledFilterGrammar` so the two cannot drift silently. + +## 6. Verification and rollout + +- [ ] 6.1 Replay the fleet census against the new parser: the 18 decidesk and 4 + shillinq entries parse and evaluate; the 2 openconnector entries are + rejected with the `operator` message; the 49 working entries (28 scalar, + 21 canonical) produce byte-identical results to the current evaluator. +- [ ] 6.2 Regression pass with opencatalogi and softwarecatalog installed — + their register annotations import unchanged and their scheduled rules + dispatch the same objects as before. Record the expected first-scan burst + in the 22 revived rules and confirm per-object dedupe caps it. +- [ ] 6.3 Open the follow-up issues: openconnector `job.job-overdue` plus its + two `created` rules (`openconnector_register.json:1154`, `:1940`, `:2114`); + and the deferred `created`-vs-`scheduled` comparison-semantics convergence + (design Decision 4) with a before/after census as its entry criterion. + +## Acceptance criteria + +- The set of filter shapes the validator accepts and the set the evaluator can + execute are the same set, because both are `ScheduledFilterParser`'s. No + second enumeration of operators exists in `lib/`. +- The 18 decidesk and 4 shillinq rules dispatch, with no edit in either repo. +- The 2 openconnector entries produce a validation error naming `operator`. + They do not silently pass, and they do not silently match nothing. +- Every one of the 49 filter entries working before this change selects exactly + the same objects after it. +- No filter shape reaches the evaluator unparsed; an unparsable filter matches + nothing and says so at warning level, once per rule per scan. +- Gate-18 fails a PR that introduces a filter entry the evaluator cannot + execute, in any fleet repo, and reports an empty scope as empty rather than + as a pass. +- The AST is the only thing the evaluator reads; a future PERF-3 compiler needs + no access to the raw annotation. + +## Quality checklist + +- `composer check:strict` passes (PHPCS, PHPMD, Psalm, PHPStan). +- New PHP files carry `@license EUPL-1.2` and `@copyright 2026 Conduction B.V.` +- `@spec` annotations point at + `openspec/specs/notificatie-engine/spec.md` anchors. +- ADR-031: the declarative-vs-imperative argument is made in design.md, not + assumed; no new imperative dispatch site is introduced in any app. +- No schema, migration or seed data is introduced (ADR-001 not applicable). +- The `created`-trigger comparison semantics are untouched; a diff of + `AnnotationNotificationDispatcher::createdFilterMatches()` is empty. From 1ebd732a934d6cd116d4728f07cf464e2e0b33e3 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 22 Aug 2026 13:09:57 +0200 Subject: [PATCH 051/139] fix(scope): make the pending schema ref single-use so it cannot leak (#2792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjectService::setSchema() records the raw ref so a later setRegister() can re-resolve it inside the register the caller names. It was never cleared after being consumed, and ObjectService is reused for many operations in one process — so a ref left behind by an operation that set a schema and never set a register was re-resolved against the NEXT caller's register and refused it. Measured on the shared instance right after the scoping landed: a pipelinq repair step seeding trustConfiguration was told 'posJournalEntryOutbound is not carried by register trust-configuration' — a slug from an entirely unrelated operation. Four repair steps across two apps failed that way. The ref is now consumed and cleared by the first setRegister() that follows, which keeps order-independence for the setSchema/setRegister pair while confining the state to the operation that created it. Two regression tests; must-fail control confirms the first fails against the leaking version. Co-authored-by: Conduction Release Bot --- lib/Service/ObjectService.php | 16 +++++++- tests/Unit/Service/ObjectServiceTest.php | 48 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/lib/Service/ObjectService.php b/lib/Service/ObjectService.php index 20250177ce..4952f6ad2b 100644 --- a/lib/Service/ObjectService.php +++ b/lib/Service/ObjectService.php @@ -502,9 +502,23 @@ public function setRegister(Register | string | int $register): static // round the two setters are called, in ONE place, instead of requiring // every call site to remember an ordering nothing enforces. if ($this->currentSchemaRef !== null) { + // SINGLE USE. The pending ref belongs to the setSchema() call that + // created it, and is consumed by the FIRST setRegister() that + // follows. Clearing it here is what keeps this instance-level state + // from leaking across unrelated operations: ObjectService is reused + // for many calls in one process, so a ref left behind by an + // operation that set a schema and never set a register would be + // re-resolved against the NEXT caller's register and refuse it. + // Measured on the shared instance after this scoping landed: a + // pipelinq repair step seeding `trustConfiguration` was told + // `posJournalEntryOutbound` is not carried by its register — a slug + // from an entirely different operation. + $pendingRef = $this->currentSchemaRef; + $this->currentSchemaRef = null; + $this->currentSchema = $this->scopedSchemaResolver->resolveSchemaWithin( register: $register, - schemaRef: $this->currentSchemaRef + schemaRef: $pendingRef ); } diff --git a/tests/Unit/Service/ObjectServiceTest.php b/tests/Unit/Service/ObjectServiceTest.php index 271ad86977..acf53e53b2 100644 --- a/tests/Unit/Service/ObjectServiceTest.php +++ b/tests/Unit/Service/ObjectServiceTest.php @@ -321,6 +321,54 @@ public function testSetSchemaWithNumericIdUsesMapperFind(): void { $this->assertSame($this->service, $result); } + /** + * The pending schema ref is SINGLE USE: consumed by the first + * setRegister() that follows, then cleared. + * + * ObjectService is reused for many operations in one process. A ref left + * behind by an operation that set a schema and never set a register would + * be re-resolved against the NEXT caller's register and refuse it — + * measured on the shared instance as a pipelinq repair step seeding + * `trustConfiguration` being told `posJournalEntryOutbound` is not carried + * by its register, a slug from an entirely unrelated operation. + */ + public function testPendingSchemaRefIsClearedOnceConsumed(): void { + // The register must actually carry the schema, or the scoped resolve + // refuses for the RIGHT reason and hides what this test is about. + $this->register->setSchemas([2]); + $this->schemaMapper->method('find')->willReturn($this->schema); + $this->schemaMapper->method('findInIds')->willReturn($this->schema); + $this->registerMapper->method('find')->willReturn($this->register); + + $this->service->setSchema(schema: 'my-schema'); + $this->assertSame('my-schema', $this->getProperty('currentSchemaRef')); + + $this->service->setRegister(register: 1); + + $this->assertNull( + $this->getProperty('currentSchemaRef'), + 'The pending ref must not survive the setRegister() that consumed it.' + ); + } + + /** + * A setRegister() with no pending ref must not re-resolve anything — the + * leak this guards against is a stale ref from an earlier operation. + */ + public function testSetRegisterWithoutAPendingRefDoesNotRescope(): void { + $this->registerMapper->method('find')->willReturn($this->register); + $this->setProperty('currentSchemaRef', null); + $this->setProperty('currentSchema', $this->schema); + + $this->service->setRegister(register: 1); + + $this->assertSame( + $this->schema, + $this->getProperty('currentSchema'), + 'An already-resolved schema must survive a later setRegister() untouched.' + ); + } + /** * Test setSchema with string slug uses mapper find. */ From a457c60269669b317fff27f989d8f12ea21edf9b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 22 Aug 2026 13:29:14 +0200 Subject: [PATCH 052/139] fix(files): resolve schema slug against the named register, and stop one bad file aborting an upload batch (#2784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the file-upload path, both surfaced by a 2,801-file Jira attachment migration. #2779 — FilesController set the schema BEFORE the register. ObjectService:: setSchema() only scopes a slug to the current register (findBySlugInIds()) when one is already set; with no register context it falls through to a global LOWER(slug) match across every register on the instance. Generic slugs are exactly the ones that collide, so POST /api/objects/planix/task/ {id}/files resolved `task` to openbuild's schema and 500'd with a message naming a register the caller never mentioned. The two setters were duplicated at 18 sites in this controller; they are now a single private setObjectContext() helper that sets the register first, so the ordering cannot regress one endpoint at a time. #2776 — the embedded-PHP-tag scan ran over the first kilobyte of EVERY upload, including compressed binary bodies. A genuine 1283x926 PNG screenshot was rejected as "contains PHP code" because its deflate stream happened to contain ` --- lib/Controller/FilesController.php | 209 ++++++---- lib/Service/File/FileValidationHandler.php | 233 ++++++++++- .../FilesControllerRefactoredMethodsTest.php | 3 +- tests/Unit/Controller/FilesControllerTest.php | 363 +++++++++++++++++- .../File/FileValidationHandlerTest.php | 177 +++++++++ .../fixtures/files/php-tag-false-positive.png | Bin 0 -> 51275 bytes 6 files changed, 913 insertions(+), 72 deletions(-) create mode 100644 tests/fixtures/files/php-tag-false-positive.png diff --git a/lib/Controller/FilesController.php b/lib/Controller/FilesController.php index 116020e940..90a4248e2c 100644 --- a/lib/Controller/FilesController.php +++ b/lib/Controller/FilesController.php @@ -190,6 +190,42 @@ private function translate(string $text, array $parameters = []): string { return $this->l10n->t($text, $parameters); }//end translate() + /** + * Establish the register/schema context on ObjectService, REGISTER FIRST. + * + * The order is load-bearing and is the whole reason this helper exists. + * `ObjectService::setSchema()` resolves a schema SLUG register-scoped via + * `SchemaMapper::findBySlugInIds()` — but only when a register is already + * set. With no register context it falls through to the global resolver, + * which matches `LOWER(slug)` across every register on the instance and + * returns whichever row the tie-break orders first. Generic slugs are + * exactly the ones that collide (`task`, `project`, `document`, `contact`), + * so the schema landed in a foreign register and the subsequent + * `setRegister()` then contradicted it, surfacing as a 500 that named a + * register the caller never mentioned. See openregister#2779: an upload to + * `planix`/`task` was rejected as "not carried by register openbuild". + * + * Every method in this controller MUST route its context setup through + * here rather than calling the two setters inline, so the ordering cannot + * regress one endpoint at a time. + * + * @param string $register The register slug, uuid or numeric id from the route. + * @param string $schema The schema slug, uuid or numeric id from the route. + * + * @return void + * + * @throws DoesNotExistException When the register or schema cannot be resolved. + * @throws \OCA\OpenRegister\Exception\SchemaNotInRegisterException When the schema + * slug is genuinely absent from the named register. + * + * @spec openspec/specs/file-actions/spec.md + */ + private function setObjectContext(string $register, string $schema): void { + // REGISTER FIRST — see the docblock above. Do not reorder. + $this->objectService->setRegister($register); + $this->objectService->setSchema($schema); + }//end setObjectContext() + /** * Enforce object-level RBAC before a file action runs (ADR-005 / gate-7). * @@ -367,8 +403,7 @@ public function show( int $fileId, ): JSONResponse|StreamResponse { // Set the schema and register to the object service (forces a check if they are valid). - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { $this->objectService->setObject($id); @@ -539,8 +574,7 @@ public function create( string $id, ): JSONResponse { // Set the schema and register to the object service (forces a check if the are valid). - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before adding files. @@ -629,8 +663,7 @@ public function save( string $id, ): JSONResponse { // Set the schema and register to the object service (forces a check if the are valid). - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before saving files. @@ -712,6 +745,12 @@ public function save( /** * Add a new file to an object via multipart form upload * + * Batch semantics (openregister#2776): a file the security checks refuse no + * longer aborts the request. Outcomes are + * - 200 + bare list of formatted files — every file stored (unchanged shape); + * - 207 + {results, rejected, summary} — some stored, the refused ones named; + * - 400 + {error, rejected} — nothing could be stored. + * * @param string $register The register slug or identifier * @param string $schema The schema slug or identifier * @param string $id The ID of the object to retrieve files for @@ -722,7 +761,10 @@ public function save( * * @NoCSRFRequired * - * @psalm-return JSONResponse<200|400|403|404, array{error?: string, 0?: array,...}, array> + * @psalm-return JSONResponse<200|207|400|403|404, + * array{error?: string, rejected?: list, + * results?: mixed, summary?: array, 0?: array,...}, + * array> * * @spec openspec/changes/retrofit-2026-05-25-bw2-ctrl-2/tasks.md#task-12 * @@ -759,18 +801,53 @@ public function createMultipart( throw new Exception('No file(s) uploaded'); } - // Process all uploaded files. - $results = $this->processUploadedFiles( + // Process all uploaded files. A per-file rejection is collected, not + // thrown — see processUploadedFiles() and openregister#2776. + $outcome = $this->processUploadedFiles( object: $object, uploadedFiles: $uploadedFiles ); + $rejected = $outcome['rejected']; + + // Every file was refused — there is nothing to report as stored, so + // keep the existing 400 shape and name each rejection. + if (empty($outcome['stored']) === true && empty($rejected) === false) { + return new JSONResponse( + data: [ + 'error' => $rejected[0]['error'], + 'rejected' => $rejected, + ], + statusCode: 400 + ); + } + // Format and return results. $formattedFiles = $this->fileService->formatFiles( - files: $results, + files: $outcome['stored'], requestParams: $this->request->getParams() ); + // Fully successful batch keeps the historical bare-list body and 200, + // so existing clients are untouched. A PARTIAL batch is a different + // outcome and gets a different status (207 Multi-Status) plus an + // envelope naming what was refused — previously this case was a 400 + // with nothing stored at all. + if (empty($rejected) === false) { + return new JSONResponse( + data: [ + 'results' => $formattedFiles['results'], + 'rejected' => $rejected, + 'summary' => [ + 'total' => (count($outcome['stored']) + count($rejected)), + 'stored' => count($outcome['stored']), + 'rejected' => count($rejected), + ], + ], + statusCode: 207 + ); + } + return new JSONResponse($formattedFiles['results']); } catch (\OCA\OpenRegister\Exception\NotAuthorizedException $e) { return new JSONResponse(['error' => $this->translate(text: 'You do not have access to this object')], 403); @@ -795,8 +872,7 @@ public function createMultipart( */ private function validateAndGetObject(string $register, string $schema, string $id): ?ObjectEntity { // Set the schema and register to the object service (forces a check if they are valid). - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); $this->objectService->setObject($id); return $this->objectService->getObject(); @@ -967,42 +1043,59 @@ private function normalizeMultipleFiles(array $files, array $data, array $fileNa /** * Process all uploaded files and create file entities. * + * PER-FILE ISOLATION (openregister#2776). Each file is stored inside its own + * try/catch, so one rejected file no longer takes the rest of the batch down + * with it. The previous implementation let the first exception propagate out + * of the loop, which turned a single unstorable attachment into a hard 400 + * for the whole request: measured on the Jira attachment migration, ONE + * false-positive PNG cost 284 perfectly storable files. + * + * Rejections are returned rather than swallowed — the caller reports them by + * name alongside the successes, so a partial batch is still auditable. + * * @param ObjectEntity $object Object entity to attach files to * @param array $uploadedFiles Normalized uploaded files array * - * @return \OCP\Files\File[] - * - * @throws Exception If file validation or processing fails + * @return array{stored: list<\OCP\Files\File>, rejected: list} + * The successfully stored file nodes, and the rejected files with the reason each was refused. * - * @psalm-return list<\OCP\Files\File> + * @psalm-return array{stored: list<\OCP\Files\File>, rejected: list} */ private function processUploadedFiles(ObjectEntity $object, array $uploadedFiles): array { - $results = []; + $stored = []; + $rejected = []; foreach ($uploadedFiles as $file) { - // Validate file upload. - $this->validateUploadedFile(file: $file); + $fileName = (string)($file['name'] ?? 'unknown'); - // Read file content. - $content = file_get_contents($file['tmp_name']); + try { + // Validate file upload. + $this->validateUploadedFile(file: $file); - if ($content === false) { - throw new Exception( - 'Failed to read uploaded file content for: ' . $file['name'] - ); - } + // Read file content. + $content = file_get_contents($file['tmp_name']); - // Create file entity. - $results[] = $this->fileService->addFile( - objectEntity: $object, - fileName: $file['name'], - content: $content, - share: $file['share'], - tags: $file['tags'] - ); + if ($content === false) { + throw new Exception( + 'Failed to read uploaded file content for: ' . $fileName + ); + } + + // Create file entity. + $stored[] = $this->fileService->addFile( + objectEntity: $object, + fileName: $fileName, + content: $content, + share: $file['share'], + tags: $file['tags'] + ); + } catch (\Throwable $e) { + // One bad file must not abort the batch — record it and continue. + $rejected[] = ['name' => $fileName, 'error' => $e->getMessage()]; + }//end try }//end foreach - return $results; + return ['stored' => $stored, 'rejected' => $rejected]; }//end processUploadedFiles() /** @@ -1059,8 +1152,7 @@ public function update( int $fileId, ): JSONResponse { // Set the schema and register to the object service (forces a check if the are valid). - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before mutating files. @@ -1123,8 +1215,7 @@ public function delete( int $fileId, ): JSONResponse { // Set the schema and register to the object service (forces a check if the are valid). - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before deleting files. @@ -1390,8 +1481,7 @@ private function normalizeTags(mixed $tags): array { */ #[AnonRateLimit(limit: 30, period: 60)] public function rename(string $register, string $schema, string $id, int $fileId): JSONResponse { - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before mutating files. @@ -1460,8 +1550,7 @@ public function rename(string $register, string $schema, string $id, int $fileId */ #[AnonRateLimit(limit: 30, period: 60)] public function copy(string $register, string $schema, string $id, int $fileId): JSONResponse { - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC on the source object. @@ -1486,8 +1575,7 @@ public function copy(string $register, string $schema, string $id, int $fileId): $this->ensureObjectAccess(register: $targetRegister, schema: $targetSchema, id: $targetObjectId); // Load target object. - $this->objectService->setSchema($targetSchema); - $this->objectService->setRegister($targetRegister); + $this->setObjectContext(register: $targetRegister, schema: $targetSchema); $this->objectService->setObject($targetObjectId); $targetObject = $this->objectService->getObject(); if ($targetObject === null) { @@ -1562,8 +1650,7 @@ public function copy(string $register, string $schema, string $id, int $fileId): */ #[AnonRateLimit(limit: 30, period: 60)] public function move(string $register, string $schema, string $id, int $fileId): JSONResponse { - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC on the source object. @@ -1587,8 +1674,7 @@ public function move(string $register, string $schema, string $id, int $fileId): // ADR-005 / gate-7: the caller must also have access to the target object. $this->ensureObjectAccess(register: $targetRegister, schema: $targetSchema, id: $targetObjectId); - $this->objectService->setSchema($targetSchema); - $this->objectService->setRegister($targetRegister); + $this->setObjectContext(register: $targetRegister, schema: $targetSchema); $this->objectService->setObject($targetObjectId); $targetObject = $this->objectService->getObject(); if ($targetObject === null) { @@ -1664,8 +1750,7 @@ public function move(string $register, string $schema, string $id, int $fileId): */ #[AnonRateLimit(limit: 120, period: 60)] public function listVersions(string $register, string $schema, string $id, int $fileId): JSONResponse { - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: object read access required to list versions. @@ -1718,8 +1803,7 @@ public function restoreVersion( int $fileId, string $versionId, ): JSONResponse { - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before restoring. @@ -1786,8 +1870,7 @@ public function restoreVersion( */ #[AnonRateLimit(limit: 30, period: 60)] public function lock(string $register, string $schema, string $id, int $fileId): JSONResponse { - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before locking. @@ -1849,8 +1932,7 @@ public function lock(string $register, string $schema, string $id, int $fileId): */ #[AnonRateLimit(limit: 30, period: 60)] public function unlock(string $register, string $schema, string $id, int $fileId): JSONResponse { - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before unlocking. @@ -1920,8 +2002,7 @@ public function unlock(string $register, string $schema, string $id, int $fileId */ #[AnonRateLimit(limit: 30, period: 60)] public function batch(string $register, string $schema, string $id): JSONResponse { - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before batch mutation. @@ -1978,12 +2059,11 @@ public function batch(string $register, string $schema, string $id): JSONRespons #[AnonRateLimit(limit: 120, period: 60)] public function preview(string $register, string $schema, string $id, int $fileId): JSONResponse|StreamResponse { try { - // SetSchema/setRegister throw DoesNotExistException for an unknown - // register/schema slug. Keep them inside the try so anonymous/missing- + // The setObjectContext() call throws DoesNotExistException for an unknown + // register/schema slug. Keep it inside the try so anonymous/missing- // resource probes return a clean 404, not a 500 HTML page. See the // newman files-domain triage and openregister#1962 follow-up. - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); // Gate anonymous callers on the file being publicly published. // Authenticated callers fall through to the existing object-level @@ -2055,8 +2135,7 @@ public function preview(string $register, string $schema, string $id, int $fileI */ #[AnonRateLimit(limit: 30, period: 60)] public function updateLabels(string $register, string $schema, string $id, int $fileId): JSONResponse { - $this->objectService->setSchema($schema); - $this->objectService->setRegister($register); + $this->setObjectContext(register: $register, schema: $schema); try { // ADR-005 / gate-7: enforce object-level RBAC before mutating labels. diff --git a/lib/Service/File/FileValidationHandler.php b/lib/Service/File/FileValidationHandler.php index 175c6f354a..3f0e7cfbba 100644 --- a/lib/Service/File/FileValidationHandler.php +++ b/lib/Service/File/FileValidationHandler.php @@ -45,6 +45,120 @@ * @version 1.0.0 */ class FileValidationHandler { + + /** + * Leading byte signatures of container formats that are unambiguously binary. + * + * A positive match here means the payload is a real image / audio / video / + * archive / document container, so a ` + */ + private const BINARY_CONTENT_SIGNATURES = [ + // Images. + "\x89PNG\r\n\x1A\n" => 'image/png', + "\xFF\xD8\xFF" => 'image/jpeg', + 'GIF87a' => 'image/gif', + 'GIF89a' => 'image/gif', + "II*\x00" => 'image/tiff', + "MM\x00*" => 'image/tiff', + "\x00\x00\x01\x00" => 'image/vnd.microsoft.icon', + '8BPS' => 'image/vnd.adobe.photoshop', + // RIFF containers: webp, wav, avi. + 'RIFF' => 'application/x-riff', + // Documents. + '%PDF-' => 'application/pdf', + "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1" => 'application/x-ole-storage', + // Archives and zip-based office formats (docx/xlsx/pptx/odt). + "PK\x03\x04" => 'application/zip', + "PK\x05\x06" => 'application/zip', + "PK\x07\x08" => 'application/zip', + "\x1F\x8B" => 'application/gzip', + 'BZh' => 'application/x-bzip2', + "\xFD7zXZ\x00" => 'application/x-xz', + "7z\xBC\xAF\x27\x1C" => 'application/x-7z-compressed', + "Rar!\x1A\x07" => 'application/vnd.rar', + // Audio and video. + 'ID3' => 'audio/mpeg', + 'OggS' => 'application/ogg', + 'fLaC' => 'audio/flac', + "\x1A\x45\xDF\xA3" => 'video/x-matroska', + "FLV\x01" => 'video/x-flv', + // Data. + "SQLite format 3\x00" => 'application/vnd.sqlite3', + ]; + + /** + * Filename extensions that are ALWAYS scanned for embedded PHP tags. + * + * These are the extensions under which a server or a browser may hand the + * bytes to an interpreter or a markup parser, so a PHP tag inside them is + * meaningful regardless of what the leading bytes look like. A polyglot that + * opens with PNG magic but is named `x.html` still gets the full scan. + * + * @var array + */ + private const ALWAYS_SCANNED_EXTENSIONS = [ + 'txt', + 'text', + 'log', + 'md', + 'markdown', + 'csv', + 'tsv', + 'htm', + 'html', + 'xhtml', + 'shtml', + 'xml', + 'xsl', + 'xslt', + 'svg', + 'svgz', + 'json', + 'yaml', + 'yml', + 'ini', + 'conf', + 'cfg', + 'env', + 'htaccess', + 'htpasswd', + 'tpl', + 'twig', + 'twig.html', + 'mustache', + 'hbs', + 'ejs', + 'erb', + 'jsp', + 'asp', + 'aspx', + 'cshtml', + 'css', + 'scss', + 'less', + 'sql', + 'sh', + 'bash', + 'php', + 'phtml', + 'php3', + 'php4', + 'php5', + 'phps', + 'phar', + 'inc', + 'module', + 'install', + ]; + /** * Constructor for FileValidationHandler. * @@ -230,7 +344,24 @@ public function detectExecutableMagicBytes(string $content, string $fileName): v ); } - // Check for embedded PHP tags. + // Check for embedded PHP tags — TEXT-ISH PAYLOADS ONLY. + // + // openregister#2776: this scan used to run over the first kilobyte of + // EVERY upload, including compressed binary bodies where a `shouldScanForEmbeddedPhp(content: $content, fileName: $fileName) === false) { + return; + } + if (preg_match('/<\?php|<\?=|sniffBinaryContentType(content: $content) === null) { + // Not a recognised binary container — scan, as before. + return true; + } + + return $this->hasAlwaysScannedExtension(fileName: $fileName); + }//end shouldScanForEmbeddedPhp() + + /** + * Sniff the content's leading bytes against the known binary container list. + * + * ISO base media files (mp4/mov/m4a/heic) are special-cased because their + * `ftyp` box marker sits at offset 4, not offset 0. + * + * The declared MIME type from the request is deliberately NOT consulted: it + * is client-supplied and would let a caller opt out of the scan by lying. + * Only the bytes decide. + * + * @param string $content The file content to sniff. + * + * @return string|null The matched container MIME type, or null when the + * content is not a recognised binary container. + * + * @psalm-return string|null + * @phpstan-return string|null + * + * @spec openspec/specs/file-actions/spec.md + */ + public function sniffBinaryContentType(string $content): ?string { + if ($content === '') { + return null; + } + + foreach (self::BINARY_CONTENT_SIGNATURES as $signature => $mimeType) { + if (str_starts_with($content, (string)$signature) === true) { + return $mimeType; + } + } + + // ISO base media file format (mp4, m4a, mov, 3gp, heic): 4-byte box + // length, then the literal 'ftyp' brand marker at offset 4. + if (strlen($content) >= 12 && substr($content, 4, 4) === 'ftyp') { + return 'video/mp4'; + } + + return null; + }//end sniffBinaryContentType() + + /** + * Check whether the filename carries an extension that is always scanned. + * + * @param string $fileName The filename to inspect. + * + * @return bool True when the extension is in the always-scanned list. + * + * @psalm-return bool + * @phpstan-return bool + * + * @spec openspec/specs/file-actions/spec.md + */ + public function hasAlwaysScannedExtension(string $fileName): bool { + $extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION)); + + if ($extension === '') { + // A file with no extension at all is treated as text-ish and scanned. + return true; + } + + return in_array($extension, self::ALWAYS_SCANNED_EXTENSIONS, true); + }//end hasAlwaysScannedExtension() + /** * Assert that the current session may access the given file. * diff --git a/tests/Unit/Controller/FilesControllerRefactoredMethodsTest.php b/tests/Unit/Controller/FilesControllerRefactoredMethodsTest.php index 8c3bbdee75..b21dcd5b7e 100644 --- a/tests/Unit/Controller/FilesControllerRefactoredMethodsTest.php +++ b/tests/Unit/Controller/FilesControllerRefactoredMethodsTest.php @@ -451,6 +451,7 @@ public function testProcessUploadedFilesWithEmptyArray(): void { ); $this->assertIsArray($result, 'Result should be an array.'); - $this->assertEmpty($result, 'Result should be empty when no files.'); + $this->assertEmpty($result['stored'], 'Nothing should be stored when no files.'); + $this->assertEmpty($result['rejected'], 'Nothing should be rejected when no files.'); } } diff --git a/tests/Unit/Controller/FilesControllerTest.php b/tests/Unit/Controller/FilesControllerTest.php index 8d7f973917..03d9f29676 100644 --- a/tests/Unit/Controller/FilesControllerTest.php +++ b/tests/Unit/Controller/FilesControllerTest.php @@ -7,6 +7,7 @@ use Exception; use OCA\OpenRegister\Controller\FilesController; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Exception\SchemaNotInRegisterException; use OCA\OpenRegister\Service\FileService; use OCA\OpenRegister\Service\ObjectService; use OCP\AppFramework\Db\DoesNotExistException; @@ -76,6 +77,188 @@ private function setupObjectServiceMocks(?ObjectEntity $object = null): void { $this->objectService->method('getObject')->willReturn($object); } + // ===================================================================== + // Register/schema context ordering — openregister#2779 + // + // ObjectService::setSchema() resolves a schema SLUG register-scoped only + // when a register is ALREADY set; with no register context it falls back + // to a global LOWER(slug) match across every register on the instance. + // The controller used to call setSchema() first, so an upload addressed + // to planix/task resolved to openbuild's `task` schema and 500'd with a + // message naming a register the caller never mentioned. + // + // The fake below reproduces exactly that resolution behaviour, so these + // tests fail against the old ordering and pass against the new one. + // ===================================================================== + + /** + * Two registers sharing the schema slug `task`. `openbuild` is listed + * first so it is what a GLOBAL (unscoped) slug lookup returns — the + * tie-break that produced the reported defect. + * + * @var array}> + */ + private const SLUG_FIXTURE = [ + 'openbuild' => ['id' => 206, 'schemas' => ['task' => 5301, 'issue' => 5302]], + 'planix' => ['id' => 19, 'schemas' => ['task' => 74, 'project' => 75]], + ]; + + /** + * Wire the ObjectService mock to emulate real slug resolution. + * + * @param array $resolved Receives the resolved register/schema by reference. + * @param list $callOrder Receives the setter call order by reference. + * + * @return void + */ + private function setupSlugResolvingObjectService(array &$resolved, array &$callOrder): void { + $resolved = ['register' => null, 'schema' => null]; + + $this->objectService->method('setRegister') + ->willReturnCallback(function ($register) use (&$resolved, &$callOrder) { + $callOrder[] = 'setRegister'; + if (isset(self::SLUG_FIXTURE[$register]) === false) { + throw new DoesNotExistException("No register {$register}"); + } + + $resolved['register'] = $register; + + return $this->objectService; + }); + + $this->objectService->method('setSchema') + ->willReturnCallback(function ($schema) use (&$resolved, &$callOrder) { + $callOrder[] = 'setSchema'; + $current = $resolved['register']; + + if ($current !== null) { + // Register-scoped resolution: naming a register is a boundary. + $schemas = self::SLUG_FIXTURE[$current]['schemas']; + if (isset($schemas[$schema]) === true) { + $resolved['schema'] = $schemas[$schema]; + + return $this->objectService; + } + + $candidates = 0; + foreach (self::SLUG_FIXTURE as $entry) { + $candidates += (int)isset($entry['schemas'][$schema]); + } + + throw new SchemaNotInRegisterException( + schemaSlug: (string)$schema, + registerId: self::SLUG_FIXTURE[$current]['id'], + registerSlug: $current, + candidatesElsewhere: $candidates, + registerSchemaCount: count($schemas) + ); + }//end if + + // No register context: global resolution, first match wins. + foreach (self::SLUG_FIXTURE as $entry) { + if (isset($entry['schemas'][$schema]) === true) { + $resolved['schema'] = $entry['schemas'][$schema]; + + return $this->objectService; + } + } + + throw new DoesNotExistException("No schema {$schema}"); + }); + + $this->objectService->method('setObject')->willReturnSelf(); + $this->objectService->method('getObject')->willReturn($this->createMock(ObjectEntity::class)); + }//end setupSlugResolvingObjectService() + + public function testSetObjectContextSetsRegisterBeforeSchema(): void { + $resolved = []; + $callOrder = []; + $this->setupSlugResolvingObjectService($resolved, $callOrder); + + $this->invokePrivateMethod('setObjectContext', ['planix', 'task']); + + $this->assertSame(['setRegister', 'setSchema'], $callOrder); + }//end testSetObjectContextSetsRegisterBeforeSchema() + + public function testSharedSchemaSlugResolvesToTheRegisterNamedInTheUrl(): void { + $resolved = []; + $callOrder = []; + $this->setupSlugResolvingObjectService($resolved, $callOrder); + + $this->invokePrivateMethod('setObjectContext', ['planix', 'task']); + + $this->assertSame('planix', $resolved['register']); + $this->assertSame(74, $resolved['schema'], 'must resolve to planix/task (74), not openbuild/task (5301)'); + }//end testSharedSchemaSlugResolvesToTheRegisterNamedInTheUrl() + + public function testCreateWithSharedSchemaSlugNoLongerLandsInTheWrongRegister(): void { + $resolved = []; + $callOrder = []; + $this->setupSlugResolvingObjectService($resolved, $callOrder); + + $this->request->method('getParams')->willReturn([ + 'name' => 'attachment.png', + 'content' => 'x', + 'tags' => [], + ]); + $this->fileService->method('addFile')->willReturn($this->createMock(File::class)); + $this->fileService->method('formatFile')->willReturn(['id' => 1]); + + $result = $this->controller->create('planix', 'task', 'obj1'); + + $this->assertEquals(200, $result->getStatus()); + $this->assertSame(74, $resolved['schema']); + }//end testCreateWithSharedSchemaSlugNoLongerLandsInTheWrongRegister() + + /** + * MUST-FAIL direction: the fix reorders resolution, it does NOT make it + * permissive. A slug that is genuinely absent from the named register + * must still be refused rather than silently served from elsewhere. + * + * `issue` exists only under openbuild, so addressing it via planix has to + * error even though the slug resolves fine globally. + */ + public function testSchemaSlugAbsentFromTheNamedRegisterStillErrors(): void { + $resolved = []; + $callOrder = []; + $this->setupSlugResolvingObjectService($resolved, $callOrder); + + $this->expectException(SchemaNotInRegisterException::class); + + $this->invokePrivateMethod('setObjectContext', ['planix', 'issue']); + }//end testSchemaSlugAbsentFromTheNamedRegisterStillErrors() + + public function testSchemaSlugAbsentEverywhereStillErrors(): void { + $resolved = []; + $callOrder = []; + $this->setupSlugResolvingObjectService($resolved, $callOrder); + + $this->expectException(SchemaNotInRegisterException::class); + + $this->invokePrivateMethod('setObjectContext', ['planix', 'no-such-slug']); + }//end testSchemaSlugAbsentEverywhereStillErrors() + + /** + * Guard the fix at the file level: every method in FilesController must go + * through setObjectContext(), so the ordering cannot regress one endpoint + * at a time. Issue #2779 was reported against create(), but the same two + * lines were duplicated in 18 places in this controller. + */ + public function testNoMethodSetsSchemaOrRegisterDirectlyOutsideTheHelper(): void { + $source = (string)file_get_contents((string)$this->reflection->getFileName()); + + $this->assertSame( + 1, + substr_count($source, '$this->objectService->setSchema('), + 'setSchema() must only be called from setObjectContext()' + ); + $this->assertSame( + 1, + substr_count($source, '$this->objectService->setRegister('), + 'setRegister() must only be called from setObjectContext()' + ); + }//end testNoMethodSetsSchemaOrRegisterDirectlyOutsideTheHelper() + // ==================== page() Tests ==================== public function testPage(): void { @@ -892,6 +1075,169 @@ public function testCreateMultipartMultipleFilesUpload(): void { @unlink($tmpFile2); } + // --------------------------------------------------------------------- + // Batch survivability — openregister#2776 + // + // One rejected file used to abort the whole request: the throw escaped + // processUploadedFiles() and became a hard 400 with nothing stored. On + // the Jira attachment migration that cost 284 storable files because of + // a single false-positive PNG. + // --------------------------------------------------------------------- + + /** + * Build a real temp file and the normalized upload entry pointing at it. + * + * @param string $name Filename to present. + * @param string $content Bytes to write. + * + * @return array + */ + private function makeUpload(string $name, string $content): array { + $tmp = (string)tempnam(sys_get_temp_dir(), 'ormp_'); + file_put_contents($tmp, $content); + + return [ + 'name' => $name, + 'type' => 'application/octet-stream', + 'tmp_name' => $tmp, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen($content), + 'share' => false, + 'tags' => [], + ]; + }//end makeUpload() + + public function testCreateMultipartOneRejectionDoesNotAbortTheBatch(): void { + $object = $this->createMock(ObjectEntity::class); + $this->setupObjectServiceMocks($object); + + $good1 = $this->makeUpload('keep-1.txt', 'one'); + $bad = $this->makeUpload('rejected.png', 'two'); + $good2 = $this->makeUpload('keep-2.txt', 'three'); + + $multi = [ + 'name' => [$good1['name'], $bad['name'], $good2['name']], + 'type' => ['text/plain', 'image/png', 'text/plain'], + 'tmp_name' => [$good1['tmp_name'], $bad['tmp_name'], $good2['tmp_name']], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_OK, UPLOAD_ERR_OK], + 'size' => [3, 3, 5], + ]; + + $this->request->method('getUploadedFile') + ->willReturnCallback(fn ($key) => $key === 'files' ? $multi : null); + $this->request->method('getParams')->willReturn([]); + + $storedNames = []; + $this->fileService->method('addFile') + ->willReturnCallback(function (...$args) use (&$storedNames) { + $named = func_get_args(); + $fileName = $named[1] ?? ''; + if ($fileName === 'rejected.png') { + throw new Exception("File 'rejected.png' contains PHP code. PHP files are blocked for security reasons."); + } + + $storedNames[] = $fileName; + + return $this->createMock(File::class); + }); + $this->fileService->method('formatFiles')->willReturnCallback( + fn (array $files) => ['results' => array_fill(0, count($files), ['id' => 1])] + ); + + $result = $this->controller->createMultipart('reg1', 'schema1', 'obj1'); + $data = $result->getData(); + + // Partial success is its own outcome, not a failure. + $this->assertEquals(207, $result->getStatus()); + $this->assertSame(['keep-1.txt', 'keep-2.txt'], $storedNames, 'the good files must still be stored'); + $this->assertCount(2, $data['results']); + + // The rejection is named, not silently dropped. + $this->assertCount(1, $data['rejected']); + $this->assertSame('rejected.png', $data['rejected'][0]['name']); + $this->assertStringContainsString('PHP code', $data['rejected'][0]['error']); + $this->assertSame(['total' => 3, 'stored' => 2, 'rejected' => 1], $data['summary']); + + foreach ([$good1, $bad, $good2] as $upload) { + @unlink($upload['tmp_name']); + } + }//end testCreateMultipartOneRejectionDoesNotAbortTheBatch() + + public function testCreateMultipartAllFilesRejectedStillReturns400(): void { + $object = $this->createMock(ObjectEntity::class); + $this->setupObjectServiceMocks($object); + + $bad = $this->makeUpload('shell.php', 'request->method('getUploadedFile') + ->willReturnCallback(fn ($key) => $key === 'file' ? $bad : null); + $this->request->method('getParams')->willReturn([]); + + $this->fileService->method('addFile') + ->willThrowException(new Exception("File 'shell.php' is an executable file (.php).")); + + $result = $this->controller->createMultipart('reg1', 'schema1', 'obj1'); + $data = $result->getData(); + + $this->assertEquals(400, $result->getStatus()); + $this->assertStringContainsString('executable file', $data['error']); + $this->assertCount(1, $data['rejected']); + $this->assertSame('shell.php', $data['rejected'][0]['name']); + + @unlink($bad['tmp_name']); + }//end testCreateMultipartAllFilesRejectedStillReturns400() + + public function testCreateMultipartFullSuccessKeepsTheHistoricalBareListShape(): void { + $object = $this->createMock(ObjectEntity::class); + $this->setupObjectServiceMocks($object); + + $good = $this->makeUpload('ok.txt', 'fine'); + + $this->request->method('getUploadedFile') + ->willReturnCallback(fn ($key) => $key === 'file' ? $good : null); + $this->request->method('getParams')->willReturn([]); + + $this->fileService->method('addFile')->willReturn($this->createMock(File::class)); + $this->fileService->method('formatFiles')->willReturn(['results' => [['id' => 1, 'name' => 'ok.txt']]]); + + $result = $this->controller->createMultipart('reg1', 'schema1', 'obj1'); + + $this->assertEquals(200, $result->getStatus()); + $this->assertSame([['id' => 1, 'name' => 'ok.txt']], $result->getData()); + + @unlink($good['tmp_name']); + }//end testCreateMultipartFullSuccessKeepsTheHistoricalBareListShape() + + public function testCreateMultipartUnreadableTempFileIsReportedNotThrown(): void { + $object = $this->createMock(ObjectEntity::class); + $this->setupObjectServiceMocks($object); + + $good = $this->makeUpload('ok.txt', 'fine'); + + $multi = [ + 'name' => ['ok.txt', 'gone.txt'], + 'type' => ['text/plain', 'text/plain'], + 'tmp_name' => [$good['tmp_name'], '/nonexistent/openregister-test-tmp'], + 'error' => [UPLOAD_ERR_OK, UPLOAD_ERR_OK], + 'size' => [4, 4], + ]; + + $this->request->method('getUploadedFile') + ->willReturnCallback(fn ($key) => $key === 'files' ? $multi : null); + $this->request->method('getParams')->willReturn([]); + $this->fileService->method('addFile')->willReturn($this->createMock(File::class)); + $this->fileService->method('formatFiles')->willReturn(['results' => [['id' => 1]]]); + + $result = $this->controller->createMultipart('reg1', 'schema1', 'obj1'); + $data = $result->getData(); + + $this->assertEquals(207, $result->getStatus()); + $this->assertCount(1, $data['rejected']); + $this->assertSame('gone.txt', $data['rejected'][0]['name']); + + @unlink($good['tmp_name']); + }//end testCreateMultipartUnreadableTempFileIsReportedNotThrown() + public function testCreateMultipartGeneralException(): void { $this->objectService->method('setSchema')->willReturnSelf(); $this->objectService->method('setRegister')->willReturnSelf(); @@ -1558,7 +1904,8 @@ public function testProcessUploadedFilesEmpty(): void { $result = $this->invokePrivateMethod('processUploadedFiles', [$object, []]); $this->assertIsArray($result); - $this->assertEmpty($result); + $this->assertEmpty($result['stored']); + $this->assertEmpty($result['rejected']); } public function testProcessUploadedFilesSuccess(): void { @@ -1583,7 +1930,8 @@ public function testProcessUploadedFilesSuccess(): void { $this->fileService->method('addFile')->willReturn($file); $result = $this->invokePrivateMethod('processUploadedFiles', [$object, $uploadedFiles]); - $this->assertCount(1, $result); + $this->assertCount(1, $result['stored']); + $this->assertEmpty($result['rejected']); @unlink($tmpFile); } @@ -1608,9 +1956,14 @@ public function testProcessUploadedFilesFailedRead(): void { ], ]; - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/File upload error/'); - $this->invokePrivateMethod('processUploadedFiles', [$object, $uploadedFiles]); + // openregister#2776: a per-file failure is now REPORTED, not thrown — + // otherwise it would abort every other file in the same batch. + $result = $this->invokePrivateMethod('processUploadedFiles', [$object, $uploadedFiles]); + + $this->assertEmpty($result['stored']); + $this->assertCount(1, $result['rejected']); + $this->assertSame('test.txt', $result['rejected'][0]['name']); + $this->assertMatchesRegularExpression('/File upload error/', $result['rejected'][0]['error']); @unlink($tmpFile); } diff --git a/tests/Unit/Service/File/FileValidationHandlerTest.php b/tests/Unit/Service/File/FileValidationHandlerTest.php index 5dfc9c1229..69faaa667e 100644 --- a/tests/Unit/Service/File/FileValidationHandlerTest.php +++ b/tests/Unit/Service/File/FileValidationHandlerTest.php @@ -353,6 +353,183 @@ public function testDetectExecutableMagicBytesPhpScriptTagSingleQuotes(): void { $this->handler->detectExecutableMagicBytes($content, 'page.html'); }//end testDetectExecutableMagicBytesPhpScriptTagSingleQuotes() + // ========================================================================= + // detectExecutableMagicBytes - binary MIME scoping (openregister#2776) + // + // The embedded-PHP-tag scan used to run over the first kilobyte of EVERY + // upload. A genuine PNG screenshot whose deflate stream happens to contain + // `falsePositivePngPath()); + + $this->assertStringStartsWith("\x89PNG\r\n\x1A\n", $content, 'fixture must be a real PNG'); + $this->assertStringContainsString('falsePositivePngPath()); + + $this->handler->detectExecutableMagicBytes($content, 'Gegevens weergeven.PNG'); + + $this->assertTrue(true, 'a genuine PNG must not be rejected as PHP'); + }//end testRealPngWithEmbeddedPhpShortEchoSequenceIsAccepted() + + public function testRealPngPassesTheFullBlockExecutableFileGate(): void { + $content = (string)file_get_contents($this->falsePositivePngPath()); + + $this->handler->blockExecutableFile('Gegevens weergeven.PNG', $content); + + $this->assertTrue(true, 'a genuine PNG must survive the whole upload gate'); + }//end testRealPngPassesTheFullBlockExecutableFileGate() + + /** + * @dataProvider binaryContainerProvider + */ + public function testBinaryContainersSkipTheEmbeddedPhpScan(string $magic, string $fileName): void { + $content = $magic . str_repeat("\x00", 32) . "" . str_repeat("\x00", 32); + + $this->handler->detectExecutableMagicBytes($content, $fileName); + + $this->assertTrue(true, "{$fileName} must not be scanned for PHP tags"); + }//end testBinaryContainersSkipTheEmbeddedPhpScan() + + /** + * @return array + */ + public static function binaryContainerProvider(): array { + return [ + 'png' => ["\x89PNG\r\n\x1A\n", 'screenshot.png'], + 'jpeg' => ["\xFF\xD8\xFF\xE0", 'photo.jpg'], + 'gif' => ['GIF89a', 'anim.gif'], + 'pdf' => ['%PDF-1.7', 'report.pdf'], + 'zip/docx' => ["PK\x03\x04", 'contract.docx'], + 'gzip' => ["\x1F\x8B\x08", 'dump.gz'], + 'ole/doc' => ["\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1", 'legacy.doc'], + 'matroska' => ["\x1A\x45\xDF\xA3", 'clip.mkv'], + 'ogg' => ['OggS', 'sound.ogg'], + 'sqlite' => ["SQLite format 3\x00", 'data.sqlite'], + ]; + }//end binaryContainerProvider() + + public function testIsoBaseMediaMp4SkipsTheEmbeddedPhpScan(): void { + // 4-byte box length, then the 'ftyp' brand marker at offset 4. + $content = "\x00\x00\x00\x20" . 'ftypisom' . str_repeat("\x00", 16) . ''; + + $this->handler->detectExecutableMagicBytes($content, 'movie.mp4'); + + $this->assertTrue(true, 'an mp4 must not be scanned for PHP tags'); + }//end testIsoBaseMediaMp4SkipsTheEmbeddedPhpScan() + + // --- MUST-FAIL side: the protection is unchanged for everything else. --- + + /** + * @dataProvider stillRejectedProvider + */ + public function testPhpPayloadsAreStillRejected(string $content, string $fileName): void { + $this->expectException(Exception::class); + + $this->handler->detectExecutableMagicBytes($content, $fileName); + }//end testPhpPayloadsAreStillRejected() + + /** + * @return array + */ + public static function stillRejectedProvider(): array { + $php = ""; + + return [ + // Plain text and markup are scanned exactly as before. + 'php source as .php' => [$php, 'shell.php'], + 'php source as .phtml' => [$php, 'shell.phtml'], + 'php source as .txt' => [$php, 'notes.txt'], + 'php source as .html' => ["$php", 'page.html'], + 'php short echo in xml' => ["", 'feed.xml'], + 'php inside svg' => ["$php", 'logo.svg'], + 'script language=php' => ['', 'page.html'], + // No extension at all is treated as text-ish and still scanned. + 'php source, no extension' => [$php, 'attachment'], + // An unrecognised byte stream is NOT a known binary container, so the + // scan still applies — the skip is a whitelist, not a blocklist. + 'unknown binary-looking prefix' => ["\x01\x02\x03\x04" . $php, 'blob.dat'], + // A polyglot: real PNG magic bytes, but named so a server would + // hand it to an interpreter or a markup parser. + 'png magic saved as .phtml' => ["\x89PNG\r\n\x1A\n" . $php, 'polyglot.phtml'], + 'png magic saved as .html' => ["\x89PNG\r\n\x1A\n" . $php, 'polyglot.html'], + 'png magic saved as .svg' => ["\x89PNG\r\n\x1A\n" . $php, 'polyglot.svg'], + ]; + }//end stillRejectedProvider() + + public function testUniversalChecksStillApplyToBinaryContainers(): void { + // The MIME scoping narrows ONLY the embedded-PHP-tag scan. A PNG-named + // file whose leading bytes are a Windows executable is still refused by + // the offset-0 magic-byte table. + $this->expectException(Exception::class); + $this->expectExceptionMessage('executable'); + + $this->handler->detectExecutableMagicBytes('MZ' . str_repeat("\x00", 64), 'innocent.png'); + }//end testUniversalChecksStillApplyToBinaryContainers() + + public function testShebangStillRejectedRegardlessOfName(): void { + $this->expectException(Exception::class); + $this->expectExceptionMessage('shebang'); + + $this->handler->detectExecutableMagicBytes("#!/usr/bin/env python\nprint(1)\n", 'data.png'); + }//end testShebangStillRejectedRegardlessOfName() + + public function testPhpExtensionStillBlockedBeforeContentIsEvenLookedAt(): void { + // blockExecutableFile()'s extension blocklist is the primary control and + // is untouched: a .php upload is refused whatever its bytes look like. + $this->expectException(Exception::class); + $this->expectExceptionMessage('executable file'); + + $this->handler->blockExecutableFile('shell.php', "\x89PNG\r\n\x1A\n" . 'assertSame('image/png', $this->handler->sniffBinaryContentType("\x89PNG\r\n\x1A\n1234")); + }//end testSniffBinaryContentTypeIdentifiesPng() + + public function testSniffBinaryContentTypeReturnsNullForText(): void { + $this->assertNull($this->handler->sniffBinaryContentType('Hello, world.')); + }//end testSniffBinaryContentTypeReturnsNullForText() + + public function testSniffBinaryContentTypeReturnsNullForEmptyContent(): void { + $this->assertNull($this->handler->sniffBinaryContentType('')); + }//end testSniffBinaryContentTypeReturnsNullForEmptyContent() + + public function testShouldScanForEmbeddedPhpDefaultsToTrue(): void { + $this->assertTrue($this->handler->shouldScanForEmbeddedPhp('some plain text', 'readme.txt')); + }//end testShouldScanForEmbeddedPhpDefaultsToTrue() + + public function testShouldScanForEmbeddedPhpIsFalseForRealPng(): void { + $content = (string)file_get_contents($this->falsePositivePngPath()); + + $this->assertFalse($this->handler->shouldScanForEmbeddedPhp($content, 'Gegevens weergeven.PNG')); + }//end testShouldScanForEmbeddedPhpIsFalseForRealPng() + public function testDetectExecutableMagicBytesLogsWarningOnDetection(): void { $this->logger->expects($this->once()) ->method('warning'); diff --git a/tests/fixtures/files/php-tag-false-positive.png b/tests/fixtures/files/php-tag-false-positive.png new file mode 100644 index 0000000000000000000000000000000000000000..20424fd3f0455b56047359ce027e8664447fddf3 GIT binary patch literal 51275 zcmeFZcT`i^_ct6xaa6`KQlto@h)5061tiX>AYB3J5Cj3~L^=ebgB1j%chG@=NC~|K z2$q1c>j3UdY-IB?n-jcIs5GW+57Bvd3M9n z_~%0shd?0E&nDNeT7y9Q1wbHy;-3V8Z(4%|cY({EP;2AgLA4`N%fN?yKEGM~1_ITm z9_HNM4}3lta@{c$1UmAb|F@?v`1KtSsBhNf>TkADZY#TM8Mhd5@;l`tEP=(ji=qC- zH?-dTA%9@CJ@R9!c)LsM5#$@$Tjk1GzYH!F>yPU0A?cpaZz4%0n=*6L67D#@efWI+ zzW8Cui@DiyUyRa1&MJ!^eD%}QZzro?q?pJ)xMucJu=#Fl8YB{dpQ(zaPW!LTOo!=o z#bVrU1&_s|n(P4K0tW$!TtttcR&}iN|c80D;s_0mHwmAVv(D0o%G(SNP;Yl6I+6nO3ke96k=4ig^O|IGw1IK*(wmSyVz zW1QvPrnS0p_T7xjR5uXFT`%(i=+kdA2J2fLXH;GL)&`!0M(%cgPJP_yFCYqxdZn@3 zY&?`)(!Gt(*k;kjp%dAAK_IHN;NHszOiK4in$^ot+rdVVotG`WE9{$Vh~g%k$9CW@ zZ^ZXBYl6~+WXJJ}PBd$QK=&gQ1ophV;v(LD!zmFd1*bnaDY;wMvbdthutL~(;0z#_ zIMY-4jcZ@hDoh!9^i<=L${GNjO=f^xPLWo6=0>fRaaC44A}oBxG>|&EwbKYym|o)Y zx;q+hpQoG}DKe>BS*sd2;_k4$zyH6;d~#jmdv=xw_PI-b!NF^)9FB}BLuZ{qMAcki zb5|#gfwh(n5IG4``2aZ3e0H2Z62JCW_-Uyf zP74SO1R(d9XhE1ACYd=G8Do05-oSV}xVGk!&gkp+B(9*9^lOzo$tynbF0Gh`&fSSr zKYu_osW$-bJz=gVgwg;3tEynFxy!>o{{ACHCDJoRRElvI1HYQ5kKtgAGm1Sg@Zcou ze~9}{)m8Td7*0tw9js4+KyEqvue$6mfE)a`iX@}DB5>bgSZIXZj*FEH5$X&qmV3nO zS5R2)+^aEKLLbE5KIIad$VD_GL)UzEapUp%u@LTDOUF_k-71)C5vTyb$dwP*%VNac zz9=pRB@O;~ggSl0xuH#{Soengz9UkUztHdfJ6|k6-nF-q+NmYOrVSaWxQd81(!J%~ zx3n|sfDru|fCw~@0Gu7^6U42F_8)dj4H@g)D-UD*41%Tu(ilm>1*oXEV|+)*1=F{Ws_K6-H(5Ik0x25- zTg(;k+HX^Vj@csXBIe#R33w|kD@n$?RV;yYeMA_m0*uZBMlW9X?ElhVIvjtie-#s_ z5l_|Og&8bwqelNYVh>L(lokT@cvJv;yf_-w5h9=SKn#qqwcl6HnaIZ^9?mg*Q}el9 zbTD?`-%{_9QY=$U>mGPXB9T5F^W zp%8Hiq0FtL7e0na)c`JJQl#cgSJXw-(+wfrvhreHaJB~^Ob*}fPFXSaJ!{)G1b%=SQV7Jpz8iOen)+LKg+OTID+yhVn# z4}#mj*sZIt=@L6X)TLvf4t(!BPj>l8lq}u52lQQ(KkWhEuvr&j%gS|h?3hNzK%=Jh z`Vi|V2@U4xcC%Z9z44ra6vh){k`J6b z0gY2XC0ee1&r5ki>$x9ii^v}AAVgahMWeP~Pil#kY5}M^Z6mn1#~_bNQgFqsmEc^T z5_ub!ko%oHtxta)Y2M9G)(((ghG9B)V1Z^eyRZVb18Wj9RBOT9V1WC+e+B!e0>|y& zTJb$$(0)pFEHSzwnX(hdp`r4NYxQr9ZXJeLim4a=o`3ADMVNIv^(2IT5lC@aNA?3j6n`mTea29WC1a(U~(y&1Ij-u}$>E8GQg^JYY zK>!*D_{R8sSp!uEfiUyEIiaY-eTzue=M6@ZsT(qP@R9-NhA+EpGb1vjiKh7<=baIu zn%rSTVeaf5y-5x1_8!pvMZT@PJu1iYjREd35_CvHOt_WEr1jc=dD)wm>zD7$T0fuDj{AISg)9TQrS7O!=&+$ zj_Ll}lO5D^t{Lk-K15XD&wvp;2=K|}z;XvdpSO6O!`U6Ypr^g{P%`p<98bo;#D#+4XfQvMSntff7m(`JA)ZXza%TB_EN}ec`pcvqd&(&5H`8AoxY|Us-YzILH~ZLoKD*bIl_i$!tE@4Q__DW*+Q1ZnKul2w-NZnmhxrne_w}Cc zoZX4^=TVrr=#eAiskMX1^t$>wvr=yU@MwW$Y8-FWI3v5__%oTT=f9iFE_3I>@?w8f z4zaOc$J;jNzzq-_lv#IAYU`TYlrj14dlx;qi^GC0E|5SCk+4d4<(ZNtJc0)%^P-o^ zJJBbx9eY~w`q2%zQ z+3-E?^}zAo6P|gfy@8~-aI~A=b&|a>pBFmzQk7MtIg^AEVXyBmj*cqMZ=zr5hp zSkKXp=uwKsy#=vmEgz`cv3bYqvfsVc^DCLTk2YNf%9b*7G>48L?JuGZgOVN*^FgzE zF^t_oPqHjdv z%0z`*(i)dq09)-cBkzZ*7R@+!F1lZ+JZ7*np+EW4TU+jK?^@t9wDh+VYeI3yWOhAv zG-!(wsH3{$SEX%CkTVb-$Ag244604JTy0QNgp+>OY~c;Ilsz=&e=`h8*DIs!D}QD+ zqYEqFgjL#hx>)}@lGQ2faz68cdCq86A%6dT?7LtKP)HHd#(&VX+Jif`*j@cx$+Tjk zJTHD@ENJYZlyCZT`E6d@xAr*{MY?1aZMU2c#|Dn{-jVYCGmWHe)6M-dN3+Qoqax6Y z81ED1g|R$qIH$_C#15I4Gqw>7hkIJT(u0m_ZCT%}dr@0cA#|rB(`}&>P-fA1|GoDq z$|5eqMl6wx332wq_*Cf|x_JE9`7YaPSLw=>`~J!&$G-Q3C9XDKFQw2;u8)>@RZuML z^M~7_3Vm%o-IzZ^yMGBj`g0%qy^I$YS{iog1IcqR-DrRLXjpfd$D8SN_wIA^Hw4vP zi>zaY@sa283IIIJvB0M5K%F`+(8Mgcs`}D1ms_qbeAjDI8VqMq0Qsu5tdYg?lvO4 ztCu=ISiS)Vxp(v3$353&xudV!8h%%@l1SDm@+XQUwS*mlNnDIl8U6SUzRz<Qu;_TGX?IX*M2gm>ctxxyj*&CkR>WD#tW2q-wG~X4 zm@%|gWv4CaY{{GoP5l%X+QqUG$<)#|1E`N;GQnJ;RckzAbO#$gM$IIJ4wWD@u)7Pm z3Yh+IN-51@gRl(4dVb7kIS4wO2KbDkPfK)$lsR7+N5-cuWq0E**dg*mlzfG6!;EIa zOaXYv9v&0w^L0ir$157r{=mP98pdeatcPii1a*3oGsv=HHrBTt+dj;O=j7~P#u;I) zZ?}k;C3?#lmwGH;7{4HbS0ThJUZpS845Yz};QFkVL;^S#PJ`JfNDtcfIkj}2hk}te zCoky4u42Onn|8j~pK zq?>`2!LJr|+bV0j;nk+Qvnhh*pI=0Z83~pjG%c4Mu7l(7JH0YI`2sd+emRm%tl33P zQY+l_+?+Rdl(L1q%A#=k0=@%PdFYKxw`EnJ#g@}zV&Ax5EY{P1+aQUeNw7>uWmuONQOi8< zZpBzNr;;{Q3C6JK#_)8K9;ogZ3vnLgaM_SIUO^5`H4s^IYtuAzG#-HRX4ibehB1*N z2(|8HNOb^v2>l*#aC3#aV=u(>aIuYnWoeI>!@j{k%&qORiUCLJK(iJhr}pPIG8>@- z3&>hB$QVl*yE~9oQlxu{x$-H5Gd9T}^IR|XDvjwbMAeEUJ@?-Ko1$ob)?xgg4j?48 z!ZS}1+t;LMpSWHXiG2uwB_QX8GC9 zrFitmMNLJ++DXQ*f#6d;NQ3{;l23k}bpCoHqc23}O zOakDeyEc!d;yU$rKf@d6=pXM!xDey!hF64%;i-m~44+-^I5Nk6uYs<;EkR}Cf%r+A zYIriZ1Y?K`R800VF1D4@R1&flci7Hta7k5HgZk+CZo4G)T?9;^PR91+NDO}meFV!% zn{P&Wy`FwL!%dBrw79@6$GeeTW#j%g;VCtS#wKLS&GIN0Y+0aAtW0`uV5?JW7K!V8 zsU*Ts1{8A1VwZ3Xq=us3e1Ny!R~)xy4G&}})w3Vzkl!KGPVO#@@7KyR3R?Nm=g7)4 zg49NT?~huEMPb28jNz+#!%SZI(&9=cIq!K{;Zbb*%L`r$zbgvlC_9eYEuw`2lpRJr zN8@aec_f&nG3{i&2|LiI%LO*wfUj=rYFNPHQ327`F7lTcB>yjp!}A1F-|`}|>69zj zw>b#r*oeESdZ`sYo#~BhW?u9)N0*G}`7v0+srBQrf*}0h5RZ5alzT!<)wO@1C(rz( zb>_LR-!;WlhdV(Q6%G~t`&XmqrrcWFvj-k{KkBu`4w?%y##grq3YVr-f~Rg7j4Yp* zzM?Uvl8{Kzj&-YX!^0h`@Mxw)g-caL%dKxd=)MU*fRB_L-z))Ag7iu%nyTA~9s?9w4weS>RI@quB?CrnpSvsKqYRV;#wtG+{aIg@n?!$k%W=#c zjWq7I9B7t~r0G_aMhOj@w+boN5`ILB;LS`>&N0#oHeJ$Vm&z;o_J}?8qw&*fj0r8y zA3lPmHtk5sX~bQUkjWX1l1ip%ss`JmaTVi0R_yTGX5Qi+&`?L1YnNtKNdD`DZ6L}o zt=|Yv`4Xg}TzU0wt3mD?cwTK@Ks@D?FKBj=1A zpVEJMmFLla!|sktlE;P5t1Uj(F=gg*HDfC$6AAe#oC`LWYNzp0Z>$yjTf=J-aqZ)j zLi1)ET)$?*m!WmO7XPCy5p}DPx-e+tq|hx|li2269uqVcS2H-Fuv$8~ApbhTVSLof++#al=&1FG9EQgAb5M3Mow6}Y^EIr)LWM&DQ z)Z6A!=`S73H3^8)2(wfY%&K`8UN9`28mdR{D{G3OCgLi`VJ)Fjz5rH`u2@ggydP~& zD&U}sya;GQFWGOqqouJcf{}+BTL~X-9Se}OiF+aesj4wl@{OL1PsAa|*-(y9^VCt0 z3m6F999}*)WU3WhpXUdM+CQk{WS)!j@ATaSdC#rh8Riy|kltlUj9I-K*#FK(`S^h8 z<4%LSx73DgyNSyI7zgjNCTn<*LXBx=<)K`Iv73M`juR6}ZC-Im94rW|s<$_En1lY1GuvjmODD+8Xu_r9H#*NWb(y(9xlk{Q_mN=tv zRg*EkMRtSbF>v6R8(m@j>y-AJA8_Cl0BINIFp-$-Uf6Kq5I$biMFvjH2w~`2ou>pFI8{!t(028$o z2$99=EWLW$!l18ky08}=ru|P%(S5)LYfuhMaev6A8)XX z<1CS=q?S=?gfO&~^EHuB;-}Aw!3NIvK|4|7u{oMS^3#?IL8zPEoy;~8Ih3U>fj41T zgu8&$N`RC{-Tgqs;udq3{?1@4{ME^JdJ=PI>`9|X$Sc!pm)!)*ARD8<%7JCQZMui; ze3j)#sSR6+zHsx>Cd+!Eyf^!NeM+d;33;oOytUYlRI||-Y(vF#Q;Z6Ix5Cqeve*}{ zf`ZltI2JchE7%fiRus#nulXja{W8QKCnZEuE8CJu5#_Q7Lu?@64Z_)83WntY(Z${b z!TXfofQ0OwTDM)@zcN6qQs|xi^X&JsFfQo{4LBXiK#K8C29~RAwZdc5N^o|oJQh2r zAuS0=%KRdx8kd=M&)1d=1~QL}8;Vab_bje0nkc4PD@>Rx##>t-%oKZ|u&fQpvmEfc zRKvE&sg0vx*PIYs5@h61xBRxuprr}7W*i~Hix`G_Rm@oG0Lijj{fMb=Vv)fy=eXZL zch$3liO)+gh>J=m$Eq=(`ssh5raiz}e*lyTMVQnq7hsccExrsbj>wI1H$`t#t^2~GSicGc-5r3l0OfUIl&7Hs!L>8#S?OH|b{cOc$RwwXr$~sF!)@IM!fN;E zWb}bRLn1(aLRo!Tc((D%d)kK$L`l)Me)~gqRdpHLk~)O|QcpQXM&x^PjnUIx{LH4R z+s=7I=DMlfdEm(Db2P~{I-o6WW2z`>;%^?dxS@31c7M zYWmehaVmv58N+esPThX1P^?-QZ`p6P#}ZNAX89|q?gl>ASZj(C4WT?dhKm8@ugYL;`{NPv-rHe+S)v#ILuVQnZ-4 z*~y(j@wnO5`^+omB09n(U=l#wzn`~r01=#i90Y3PXY0RTUT&J=HMN74`bW)sd|PJC zWKz8M-_GA^%|M<}vpAd1HOEy%?X0(!=!?qQ3;zM+7;X5&)MQ+`e__CDoc5LfxiWn? zr;%-aGkevU6Bg9k=dSf1e!Iq*T`N)-0?HTs7~@EW zm2kuA=Pud^%dDQl@|^tM)2TeT0VG$LO#336e5A<@3HjqDCXAmEx)N*Mh>L8Nj(zYm zAfxS<3Y0*~ukbf|*5V}H-b#AlP){okI&|o5FTFa~wwRykap~#^F?+jNxZSxpFyCWFhFWA-wU~N)Bqj13xDXYzswq3>%$Q-QnH8d z>`l=egGo&1oZIhvYp>`=0R|%{NzJWIX71WsPP70SU-KtG1gJMOixwm6#ACYl)?8q~ zsd)~q%XusLfB0UVfp-|FOROD52m=mhAB}=x-p}b zD?9f!`uTZiz<{p~d&^liZUZ!F#$SkNZmIC!7_5@>%{7h!`QkBtY39fV zkYI!{%D3f@I-0X;v=IET$|Y6~s7WsWRmjYzj6D+b8?F2ZaCwLi>nlZ=_1oGkV@KbX zlNpw&mM1cRB{cq;YkOw>`nRs-yis~U{n_OE<|ndUZULo;9xZ+X{|Qig`Tl3uWA}XJ zvqirEiRU4H^-X!NJ%p_>!dWiwZa-QqcrooiHapfucQ0stxL6Hsd)14t&PN>iU(uk(|%v2M)zUygx5 zi@E$Dzh0BQ`p8Vi%Zng`2)u2fnSM@ia*I=6-PEc6BMc}XXCT zWF?w!NGZ%+#v0F6J6}!Y@n6-0lE?}UcBc9}VYfB7H&Tg$d+W%o@SZF*4Cu4MPK}IJ>HFyZ8TGkACz5&hw=TJ^~&B7Pt zgMFa6CWg{U^@G8E?WG0e_J+RZ3~z$!N&Ut~PCc0g=e$Q(3T?`X{LN~|mAr=6vF#9R zvtwdzCoyXuA#T;*E{NvrU~C;2kJ>UPB&n$~sYLr^^^n^};=0iO!|2qRWV5xdOKEd{ zRmCnBFWtJ0KRevw`jNd$ZQeff_p5=ak)d8^FwkR}q#n|3t+X;=^!*Z}bV^KlCi!i} zkDTT#QG)0{vi5zhUKd*`RETO)UNMBS|G4V%BiLoasQjj23gURGp_mwCKw8w3$4TDt z3Wtv3`Y6V1^83$zu6g2JC&jGdJr^sqlKy7S$M9kEfgYKX%Hrv$W%=dp5nV|BbhZ8Y z{ROLJed5!%k>K91eUq|pa&DkQG`VwmkGC2)(VX|ot%oV`CuNG4lMo+Myyz_@u{2{C zjehkBY^$`@v+f?$&cRYq+n>;#Uo$nYOJ8C|a8~lVb)mZNHH?VKp-;F?bwNH?0p8^O zSy#pT-9lqbRX}Mp=QaH_Mo(K*hHJB-zMeJE(zy0vJPEd3R0>-yY9*Ue&D6JXMrRWL zCOyofwo8a(&0+RI65nP7W3$4Rz7k-ocNVr&BYOv%x6BwcTc}HQ)>C^h=G2lEqYvBZ zj?O>S`mO`Yh8MysT9mWyPa;3f6rC58Y~3A7CB2_&Xriu+`pQ0fYZ2}>TVkJr>Iev6 z`edYfa)yRGg`|?IDv$PqKWn=k8d+DyQBDHjL3-V8)f!1IL#lK?tWi%;U;pt)21`lJ zQBjh9uS0@s$EZG;MI);k=Gb%RrdMir z!j%{$)ia2#B24YH&V{}AR)(N`hsl(9L(AeS!w z3=ACLX1!}@u^~igVB-MbAimTklNxAX>)Yo8TwzRB6l-#rwzGk7WB&4wCS9+X@5p?R zik~@SOEQLeps`tj^zGRs<5gEbg{(yJbyob(llY`m2ZQEG0_b_nL zMJWN4!7pIkrtUwM@Xl}iwGp~G8z*^UyThb!BTH?LfB&0mAkC5); zt{T(0AzQg)FD7NZD9`@Usy{}e4&1mWUat%^AS@CJ{qD@dvmONgJYp?4U_Q1Ypl&E` z3@gv~^{yT0!9=UaN6?LFjaLveb*m+P`Z@j-Ze0}SAC`yx?&O*ZURapgCuxtnbTOW& zmGj{6V*UfGUiRdvthn8mx2H{R@@FLp+?y{^^&T7*tJY1hka!mPy&s3Kan5g76ez*@ zP%GiDD3%9{Is5-HVrfTA-CnrH>Bn!Izi;r_A$i!{PsXyJoLj{#s@H`)o`1sos+P27 zFRab8o}7FAq%v%&Km`fT1^hnY~3`=w-BOByt%ubkiy8L$=Ewr zz@J0l87-IGewj!?HgKO{FCI&*I0os~7{RCL1I8xF=SW|B`u52(c*uXPsBrB}dK=5eHfyUp!5mt8g0qSY$tihs%CuAvM{ z#!w1qi_yJV7iS>qnS&5rld6?z&#`tB;DV;PB9JLi64W z{S&;o64418!GAdR^r=f9+)%Lbw`7?Xot(F+eqQbw&&)bh{^K1u-e+wvCu7%oSy!-W zF}|Bns?#4opGX4;<1*K~9{YphsaBr9Vl3yJ7c!7x&Y2JXF-K<_*3)Y#_UJ|E*3KVK z$edV~mOvlL8WGp7V|dV{qkC50xN1Xs9xo(yQP1~}jG*NI_#mR*QysO$t8UEO9p3JN zqiOSgTM`LdrTfwP{~;^w|3s$+Bg*&eF53gaF8`YIf3N>LAJja-GJ4{Y(z|XPHgA30 zVS9~xmW#!312{){8$JzXC1Y{Ej!yD5hk;xT(0m89zFO{%;mB5)&8wU$5yVN%He~uj zO<7-AUwI%9CG&uk7N}`qX$kTa!hEM=YSx{1Hc%<>1n54ilt0>(5P9k;f`;OG`c_7& zm=Meff*|*Jey8nSUu|OnZL_HZAW*0k;FDjr@ujCW(1TRdxA%j3z5$UH=!TzRQjfu* z-$3`<{~{%wrK{EESO`ScYLWbPznGke$;=Ad5YGT&xS^kcNC8BSkLM;_%+reMFzI`d zvL6Ka8+7-{&O^w;N3|FS790aD}@8;JHlpW``@9CT+r^KGJfC3x2Q$T}mqcNZQPWbmb zzT);n&58tXeUm;xP|pemAtj%Xi7kz>v=OhZGaMflzpEs zlrrGAMJME5HLLV%T@bWw=YY zOi>utrK*>TRIe)aS=D&{e zZ@Tc{Ctw@=TaW#V-13|8!-}F`05Iq1^dUhua(G_d^vKQwrO_&&6TpfaL$$N1hFDb7S=P)tGbt7D`Xs-C=be=k11AN z0oo?-xAPJ3@(YG{6sn>^SycBe0cJeKpV4C2=k)8J_JML^`I9*1r`kL&k^nFY;S0-L z!Q%S`u|}bazsN@oCY&V1?&M$6zGDKZV2?I5SvrYioWZp%nArDL`@qK(dDb#e}>lTtajXXG+-K0x1axveGpjS5y`tt1z>8!$ApUy zPy>^A8mOa^bc-P9*9vST5kT*(9+1iasX_Uh$4_Q@%@Vp`c)C~pk5`_bHy?f-i=*{a z1V2aWC?_FxYM-e**&)5{eMkLqqqD=ywc5#XlK8Wd$3lh9hncZvuVf>)M6m5d^sM`? zbgDtqne1vx(%cmFr1;Zm%G%ScEV6S0r?)LI4l3kUqf+(SFy|D^9p5#n7rAu==cbHz~K+q%MNh%>=x(P3MvFBn4$S%^k1P2@fyUk^`C{H!~UJ{^Rv zryH2MC2+cQ@7?wPrqw<29_Id8yAgD`_3u5a4ECgX(%kh#wqM~r+HN$Ai$|+SbLJZy zI=ua*F2*T5w!(;}!u3QPvFd@EN2*cZBF7%bEb1W7U7OL#necDYW@Z{J|R-j>6 zbp)E^x8RTsPC}P-*`njlMW7syn?br1+=jQ=)&@5&P9P~z3Gi0KQUm;2b!Vvm$|a_g zR5ZGw%x+V~HQLzVtIn|{aN#pqXZ(jzNjq#dcS^v=TF~uO^4#Gp^2d#BF7sXRc+TsD zEV5$NROWan$`1n5?u5m zs4G8nhxI1 zeHNG^Rxn<0iz*z4cMvQ;6T1AfPM>Vwq4&~_V^8rPW_AQdj4rEICf^T$gViU=0EBx; zFaKK7bg*YZiIfdNLsmQH7Qf#6v*o32T%OjeG9d$oQvkX(A`UaqU|=!WquH@)rbIg7 zJhn&Z!-$oL=V@o z%w$jaM1zpI;OOhl^k0;8Vw9y#8krN`Ck!F$qw|vw8_xtdR_|B`OF=FPR~%VQ1al(D z&)<8!sW?=z^&_$UEHT)yAS7QT3cp+L$LgZFP-lHy1bdNR(Y5rB`9_%%Ls5O+%HoP{ ze)fZ4`xZ3Z@mmY^YuV?-QpZUh31e;KWWvVeLojw$G7CRfE&^jTjt2sk_&(MWu#q7} z7LI~;QdSwy%045umiAgj#Uc#WmnA)ye;)K$PWJa0eAR7I0Xix}IPM?plqcdh7)(5d zw(1lJy8)FZ1YAZ)x90q|d@>zW*g!?I_ z4IPocp1z>d8%T*I+a`%W1^VeN`6ms~uK&yb_t(3m9wkUWz27SEBi)}jcFJ)SN5PC1 z?9WAX7u2mY-QK+;#~wU9X2qig7_=={GKoRFdz?Yu!ch`}gW$*jw=Z`$Dc1;u0%_`> z>Ck*SG`BnvTIL?l4ElR$>YALgdF3p7NG3pGS2|m+rb_ru?RkM$zwXj)x5Z%kv?Sr7 zQ$!1gC^-1`9Ifl&qh(ASwxgDe#Us8U8XqGFPOsrV`!1!g+Na8J?S)@3Ki1w`Bewu$YrLQEj+Wz_H2Sb8h zzq;0}h?PWS@aUP${fE4B{(IY-v*i`?aTNm%yS5*mt!0A!vzr3j>;^7(OqnAdL2@-DeWA(Y*A{U#e0O$d+ukwRrvNa(!*?jL~>2vdEG0Wx!U zZ}Y-xDhbDB9KBZdVzcY+JX+yi9645@VVwlLso88#b8>itGMVwH9~;!DxrC=mG8599 zbF8ln1WL&fW#~%?sR0<#+<s(vZNgn7n z>&`i|QDEdnlp~s@v8+nBy^nKaI5|E?db80y)BuiW&sj>4J-#NLgv+kgPy1(^PLt5u z;dqn$Rj`#pG6!#))~~=uReBNSi56+Bq&4KncYnQ^PewL&sn?yxz|^=3BYhbdn}+IR zUcWZ}CVt)CKjP3eGT2OmwNSb8<toBe;(zkD2xs|E_u4J3;+L z?Zk@M>r4{ZqCEl;ZK@D!@Jaf=;agdA@M`>$J7)}ROnUUBJTkxT;MLrp`C@rsA8)-Q z7EnwA|2D>2P{4w3sD=Lbiem`{#j3k*T8juGx3P^mIF(=+rC-c>_vOUXvcUJv)`Fws zgqaT?3@TS{U6Vf{@>64zv9Pf4Z#0LD`$Uu(Qq!J zq-q){v4ZNJo)Dm3JBDv=xqG?~m^7J}AH16c`>vcy0Sh5GKY)Yq+$Z*faC_4lIP zj8u{qbfzWz@oR})yH}b;@|*{Ho_>znGE?e>BviHh{AHCt5_sHXcI#gFdAI3%KFTN& za81Kzt4xZ3rE`(zK+g{TW%`qp@&DYYcJE9JJjP?6T{aM@sFV23VT^T-65k zN8Z=2@*De9F|yJZqs|!(*5V>oo1@AcB;o;=IlQc;I)NXD0)z^B%^E0)dipjIS6lrL z=eO$}(+|d(z45B|qdc4XV+44b7`V&fBrvD%rh@^qzca+?cqAmYvc>(-t~2>WvwC1Z z{b!orlcKK5|J-_abN2Bw3Ml@bzZ6(st8HlfL_PAx8AJ8)&Lc}H*j0-y{3(|hat$pn zRtLMPQ?5!*G00q_jL_1O-V1=SEwO9Qor8v~oZZB$Ry%d1m-Uao&nPy2e^q#xJ~^Wc zjlH_)68vqvE`WIzYQStS@IO&g{j@pGB8B(khSc)3EktR9E=kS~d*0PY;$)(>GzWXC za!6keqe%~N7Bj>aF+o$ejKxuZbm(r!JAd>UJMMN7jL`zz@@UG`t9D__&!YoNeV!WE zVTAioMfd?38w`H4;$s7qLQ$8-!S-vxl-)s$tWHh!rE={A#+5GEJawC}J;&XQAxc!) zO>7q#3IIaX->~5kSCesHS|AG#l{HxASJDULU%8x#MWF_|ay?R-mNW-394hCp_}M2(-htwlEYcCa` zYo^)pG5%6OFP6wakKEBN(f55Vy@Ao~j@b&}oKGXMb_L;GSfW1wN?QeiC5yP5lL`~ADQ0mDZ}j0@{LV^>=_Z0XXUz?ivkVd}isLdRFayv!_CJ!IU1rClYvN2Yo> z0#0CFubYYqUs${=Bps{bcFAFQ229lJo>76WN@959o<55HqQZkU3reO**}Z@GtxvK& zZ{22%O5#5VG%p#jbnSbmD)O`B0{H<)O8vT?d}p8FN#x;KTzH zUvRTsK*D6J1P4ndn3N;f?{-2GA-`fDhntf5c7mVqrLGL1FPub ztg!b1%Q{*z3UP4%-PjhzlJKtdO@@=J$=4Mi^#(dwAylZ-M|w*`G|fopAMlIBrN5m% zA!n&56j6M>U3zZ)*;hwpSP)U9!j!wmO+=4kRy5;2Ytiq{DlJFc%HsfFn!*`n+V z%41O8)USjak;%gf#<9)Z_~y`>PMEzCK?01{#->e8=aEA57nj`ciR;FeRCcVrr+QT! z5<}@bS>F(o7xneLSNe=me&_d65AilGasnZ9%CBpcRdEOftpb_|E*s>OjCA@Ul5CK` z!FWS(qyQ-6CgJxKbaW0152i@20#JM0KW_0W-H1AJ4(Q)k+VQp(%+Acb7B2g^Tv5s| zaba{m&B`Y80oL(0L7^q_(RJ5@Vdo-vCCzLl_N4y8@4%fLsaDLp(m##L?mW zLUvF-eZSxCMc?ScpA#wBagUSmS32hwqh1EbXoT}mhdlL=nW0_rcT`n^v2geyXg}CXzCO!a&pI_Q7Od* zm-e7Bdc`KcNwENx?bQ|<}q3M>#=7&r-lbkH>sAchUn{a&{11Xf-;1&*e z>ZNelvt%^PY}L-oowk!eEjESLGIz|2(;^#y|9120WI;2|_U3K7X&PfK`I%#0!SF9| zzoi;--{`6@!(#ZB_*eHZrn%G_;dmsm`<{nnqh=M5;_vbJizptG_a#MlJR;9pnIj>8 z`E|+VvYO*h?4e4+@=5M{)3lQ^JznWi4_>u2&s4^1hX&KSI(x^kOMla34LcjC&-Me3 zJooe>@ABN;mYV<%w+(If+aF%LN!7SOlR2HA^F|}@MMY&aplyPYsJz_Majf^S4`*co z##jIdvZ3X>=~UmvWS)P%?bran(mAzW=P_5eX@;_39c7X3?DgV-)*hQg+V5dMeD4($Fw{dtQ@eh~elBUH=KD3-SE;Lcgy2t+ z34Rf80-PT+`leYCQ_OFGkK#ksxE1V?30(+}iv3e6fE9UVy1HutFA_kg&U(=2L8u5y zc5VnL`g2xEbli&=3=n${u!0eOKCdzzin;Y}&Yu9E=~23$W3{4w7xNvrEwx^>X)DmUTZxd(CTBSvpoR=DD z#`3qqp!ThZ`dP>0kyHb|hmX5kIMuA0or;Py&aknpP!v%5VT$*vhfmk)x@mt{;a7;3 zQ+|9OTxjjh_DsgLb$=W(6!2QJ$FdR%NG$;+KzhEW?*|l30gU%leXG~`ZP9KeGX#51 z_r~Aez~emcK_aH(_cnUB0rPj(QaJN!F#MHWE*Rs(E5nCxel7oB?7eqbQ(5~q$_x%z zKva-kMp2L^y*I}KgEXc0UIaq#T`YifZg2T25LPig*oa)zS3ngz{Oo` zi@1s?lj8fnFTO46R*4^lB!+npDDkllfTH5DqUsV=ThXVys0Zc&Y#1x_jC88HxOspvm(sU^Z4BZs-KVgpEeFz^bFJANaueKD4N=b=PQsvw~ z-(q!B)wP~;<=DepmL_OrwzhlPFPV*00Sd9(7VZ3?4W@dhTTd5F?cNp7v$ z9oExL$7Sn3p>J<&T{UbPb4ox@b&iVucxe`I!hBEm`u9WqBwIIRt1t!2V)89L_XOf& zOC1p?5ILbUnH@bVA|xGpizBT6y2A~FcAPGOk36BL%BN)r_R=}g?!oXz#(`0y zL@cLxip{*MB-3SY6ey96;)0oTLr|_zK{*0pBROn}ckDH}xyw{w)kYWIr?540Bs+GQ z|7fcpUzhc$t>PBOZdnvo2f7$o4oTGF<@C^e_iS53J&b)N8z0jvW8oBAi{6znn05#j ztS*1P2^$#vPQY62_Xq{|C@4GPM5Row$hxL6K7cJxjrwKp80^hMy@v$J zMfC++@)>+u$TRB1^{zflosrDBoQ?||3#T-TN?ROg5oqao$L9()R;aq>gBE&ZR()dt zNmZD%j?mQKKU^BR7X;}%3!|9WmiX4$a7Rzww6xKd_)nLeIBvoj$1BM6(Ygz`2ZU0I1VH)Sf~Y?FF{$pdvgPX#bAz%a(9*$G!R! zth`gQ(W2v(?x;p5ujBlnPywzUsU6T@ee1qQO9Shu1$`5;0bq+%J9KC1Xd~Cp1{Yq< zmoa?Pb}!s-yS7R{%iTuXoqq5tVgt8>d7jd{uD3Ds`Gr`;j5f4Ch1zweululWW7eP! z?V|>hTdPU2-rE({^}y+HvVciK>iK6DT(zI*6xGZzN})Jdi8IF7mpjLUK*q2?yZ$yJ zuBB4;g6J*M{P%5sJuzerhGX%|BZ|BFvh)O(Hq{~+VxcS0nvnUM)Z%hqrc`eo&kB08 z-$2@oN)NiDy*L?W3MR;+PBk@6J)P`8NwhJ>yvY`^g)yEI-EoN;aeXV#2=4Jq5>W;G z{9hsl{P+6*)sfUFPZ16F5Rr@9!8eJai|Kx|2-&H?lu$|4ed&=zc#d~(E$JHhUD_HS z%x<)J_Jpnx)|VvvF$v}eX%08#x#T6BK{zIR+M>)<{U@-KW5k*bn;C6%0*Kln<@8W? zaOk)_*8B@-2k~te-*iZEAe?@1v90l9c86+)W}g*#py94fN%4ucbda?X2j&iveWHOQzofP1Q#QE5?1I!8VJZxEh3V; zuM2gX&_DCk2fbM`vcI$`eBPason{x_OkhNW8iL1*M*YT#Fjz)n%SOsdY?`Zh>Pp~o z1-@(+V_;;6-ol6@W*(W}V=@q+TwP%MIHI96rL!7dy^OgETP}`-WsF`5)EuDKW&O$W zV3jTxUtu=S^WdLz;+yV8EBbo!mq0>W+fuBTE3JitDk}NIt#<7B4o23smg>3L#**>Q z@pfQnBzlL>Td_=KLlw!4q-d1~ZS9R$5r;(3>uh{P1^TeY{e_V?GlrS5o1dKlWaxqW zn;MZ{*5$PRB~RObpq7fY5E3}QAv^i{xxLly=On4MoD^#SB)VH<*8&}LVo4-y(Z_}$ zulSXLMu&*BF*T>s*MG&B50Oj@IkZQ)ADY;v9h;J9CGLc-L$7H^rfRRf*!3iV;Pr)1 z*23S*UV@P}dgoFt{jLv$KCOkHaOyJQUi`@XdMXo!K*4V-e`-KtHR+V_)PP!G`YMsh zp9lT*BNgi?hq(cozn^IOaa5x?9ncd}4Y>KpqjRhuRF-^RPaE>toc7-D*M;VlY515| zP#reIX$OIk7I?SrFya3wo}2#^o3xC3+1}pxijd@qa@WtK>UdZu9{f0z=rScv&eGN> z$zBZcRbMI!{Q88|z&tG4%%J9*tCe0{1QjmyM0fOL8i z6>sTMqF_3oz)Z1yuW%=vDKV_#SJ%6XB5r;sN3dl*c73=JLdu)TAT6`ni$$_9Qn~CN zi9$z4b|Frr^xhW7p^wA00T;87jcE7d4$ZO~+-3yr+EK4~s6%B(HC4U+NwfE%$Z`zT z@TKmw2D&B&W)TzA2lF~ka#w=8)rJHwlRzq0;VHyRe+5ifKO&2h>V+B~oEq=B_I_9H z`L}lMK-R!yD!O*pybxunE9dvYQX+0bbxQOXO;ZXb*0C~PWBXFK&PK1=^|f8`i=e{| zuIqCa)e_mg)l|Uz8d7Ke4X?(`1I!0}5}?i!9obZnRQ27%MpfqyF1i2c;nz)GkSvO< z%P1>+cOvS`VsAFUE|)+R|NO`KKdkbR&fi)>3&?fWL+T_dA8`15E>cRq1jMMP-wn#d#rxOi@uW|_ zQ-M*fby)kt>D`yP)UN=x-9Ap`A4dhbZmxpQaOw)L|77NQ>meX|ZjMB!1FTm2M-)C! z2YJcouAlbsp8|}2iEJPsdClaM!3Ug3?=j(jDYpIfwHL3OjJ%Yhc=fFj3Bcq7$L^4h zy%%iGBQR-n6ZSNA#+)yoF11zs42d8JmUE^zVQk~m6WprN)po7i=aXK|(?9#ih=sPg zT6?b-8x}ucRyU~ZJSF)398siw1ER7#hOeE(e>ChB7N8q8!p-&zWLI`67CW&Y_ zDR$!{udNJ)ON0izwqb}lWMw%nb7)ctKy)h}p>jP)VIobx3%5XAzEye1UV2t1k#uKC zTtBwywl6KD1Y2ZankRRIE|YQv$6vX(+6wr`kH(xvj@AeTco8dJ|RK@rCb$ z?m`rT`YzD)*yrZvY%C#+a626z^5hoZS?ujCY&tcI)!0+SY2e!iZ~Adsa+Qz&V;K0vQWJXs6wh?TJ8(-CPN|JNN;0SeKQJ; z&9}W94N?Y@`)=Q7lv7p!+IK4dmHoOcXX;n1Nz-*Tx~edBQQk20{K}u9a>5~LjjaPz zg?w9OB0_=}3l@~w`<(!QyzsVcW`1iNBR0ql&d?YHrs0RvX?V(`+_Y_*bl!ae$ru3T zlh{N)l*brdpxCB)pETdA6~LW^C|vkd!~ZK~XtgwZXUS?}KE3PIz@gKk*o5?Lau)7O zNt16eA^ToiTXLqpjz0n>i2ZJx?rm!(8O$aEU6t5RpI$Q_`0*=+)Cx)z-|iYVPAjxN zXn$VODnns)GK)EPfA#}aA#3;A_ym5lFCA}S82X-&vhbF|`UEqSebZqN@3y75{X{P0 zn*nK-jqg9Exc#fc-ME^!wo=ZX3S1Xd!l5g#I?b0_iwg!eTb2MX2HNq>Z z10!{zo^tDL787*C(znPj4D8jpPE8?JzYp$fKTCjPbsn3HA{8nX701ghNoTLsqPTs9 zv~n%n`cz(2t`7c2Mo(iqe%*6#vgW(u?(%8)RYmKm#HqxopC%d>>tIAyAe6==L}YYu zo#0Q3fCoiLL)D%y@*kvbaQ7#H1RtF5>FpoQz5f+|n>2@yN;7fOC^ALtqput^cx%dS z;?veqoee2Bk5X4u+k0gkOiypR#ny2|6(6WCKKNg=awGK0tVvCJIeuM9y~}&5YAFMd!Ba26NLpy#R-aKvXVu=Ii3aH z(#x_MSyzRkpDXEyn`dpJoUB^y1E+$PiG|VwEWRV9Tt2dOzyXuYU>v*_{OZS#^+r1% z^rC0K^@$<^JEIKBKvmo8%de5f9?2OD(tp0hmR@m`u+)W@?*O|HtURFgcTqg!fkk=^ z|Cxvklq5X&?(*^wTTPXAPv!NM5Te#HG>JsuB_kWrV8VyZPJJ7aq(j5jEaV^@fAkr;FH-8mA7~y>U3&sr zqLY-St-=E&HG#_suiEgAgQl@+}!KQaji0jNM6X5Te5duEy!+TR7=bKmm`B-<-Yx zy`@EER!uiYxjz|}0v?1QO5(9r`eJllnfLzWx0*JYBh>oKHW^jmv&(0-x4eVxVla!= zANxojk_9`^*M-hsKyiP>mW#4Yu^~KvH)eGY&>z}xD()K13C3e zk<#ZT?em{N`j>E1I&_xXkdzYUELJ8A%A5MZ+Z&gdL-;0Nze0M7iq_a!9HApX6#(Xt zIyDP0akDTLHpET8)mY}IdsFc`tO|&gps8rOEDah`sJ2T2nWhQauekh83a|e!_>PJ< z&p=*?1tPx$?yV=_A%waq4<7+yrGre4pWL?Xc=i+|k1EJXd0T9zjDJiFz4F~Sc->+m z`K7P?R&OE50Kr-Rx+8Z)}VDw9uw-Y{jdX3IB+~tcV?FTG#z;~2M2dBjW6!H`lBK|2h%&Lgg*xk*032|jDCQ>LYK%ZI zcJkYNm{f0-xB}9{klz$%9QqM*(!blhTUd9`ec`3LJ7L4tJIC+{^4Wg!OL4wEE&GbG z{-Dx~FpAr1ZTL>Tk$;B(HNiPm-ES?{RNam?-DG?QtCOZK)v0%=CATqu)xEu+*6gxp@DmPViDZEFr`EXd}hC$mujF zA^!x)EvWr-$|Id%pfDbBG0B5?yvBIvKKw`e{4?n;5aVUp)|Ut-;mbUUfbmIlXF)Uo^BP1{@6sD~)clv4CUA(*6M7ZKp*c zk)mD=oBZ<0hs4AMA(fT%C=+lmw3B1W_(c;Qhw*68lHP}(ADp|dm8(tBWus#eb?!dt zJTeiWE?`|acnHRzF~oDmUWQlbpI-ssjWTd~lG;i09~$3yK}g4VQGh4H7%*o8j&U)H zy{OjS#*T9#X@Y*H?LTHOsG;4CSu#ukEMCpk$I z102zPV+{%=U$=)P!QtCr?*B~9=d*h%QrT0%%hitx6p|vk7R5jOL-qtt)(#^hg z{9;oD!80>9I3~7vFf7ZxbZ6afD;mf588gZfb3Cwvr8OU8jMEl(mXYeJ5#Gz-m8 zGAo2#KUOJuEo1mMH~`>lgEnd1-P{T*3qRp4mIwxhCFN(CqZ2fBiR+zNkB0+vd3Qb% zx;@7*SgY0WnR3nP%tk5(vNJQ-h~hiQ->yq}9ESaf)zQSuny%7r+{Ln3U-=X!zJ9t4+^Fb9PF{=gns4jg}$E67VOk` z%_~^0Q94x`o`Hb8?E0fv>9|m+GSh89h2HgpbSO29Ft9Wj8cQ0N2w4^B-LSRAjyAb( z89R(s{Q7<4&Jsv)s|AaEPcVLmEuZ$EAqO|O4xTt-aU?JV}dFuZ{j`;~MJY zJ9VHH+?5h4bcr)RwLVBh_GQ4P-({IAM|Hf|+QlcSY;3@dHTrT#zi76v2X*43e6A}7u|0!lhJD3}) z2lYP0D|Ojn(R;83&y)oc-~kkFjTbjTF8qexizK;2mSmE#4s) zAxMK4Tz$`qq-tHZ7q$_3vBX=;t34cGo0O0By$lpn)AKsQntB5H%sUBS4hpRurHA)K zJCxgKF3F~aXkd-B;1Xy-7(qTZ$9_L`=UYDyQ>;N%EX4mPau7iqXtfQ^g861GiN1yM zDPo2;P%S%|8)=KCq~c!w{T3^WD(=&qZF~ND!t1ULx4reBg=Ec_J!5f_5 zE}>X_W$-{^=1b$<^})Xu)^n_#Z>`6N@7Q`=(|c_yi6G+3b`XB%M9)_DZ_v;U`_f}^ z{)3o{Y;CIr3fS7R`s=V-+AMqHAnsm-yiav;yhm+z|S^CMuI!ELp9B#-bvOb!KkwKeR>`OmyxGGY-! z+Y1X15&7`MrL6q2EBOLNJHmQ{D;O_FNYjUg*6_(S>w>9Qk~qdvFQc_^ya9_dTn#v) zbjzDcy7V5%3#+J9YRsT9Ww+FYMDri0V8n55>t7S3s3bxBABfM;&2&4PIb*VrpB2nnWp3I%zG#irreF5Am*(>#*Q6^n1B z1sL2?Zrj7`%5pq5Fv9xa$o9dw+g9jK4}R0`Xfd^|aml-@t;p(=zS=e;cQLi0j(`hy zF|9OC#O+4cae0WC1+%w-(XOt|D&Y;d0y^TXs7aMRMJ_==leWvO&gz=c<6$M4idw(e z1`20bn5crfU)1XTL5I)tVv|GjgQwR5ER^sQvr}&-q-DjW^!e{E=#;d>%~fx+Si-r^ zww`~5V3aAB55RD{zYXx0nL*^)2eJm`8A-)bvBxZ@?C5i&Kk$WW&A;Kwlw!0;>hci+ zW)N+aH1|Vp*6Iv9`}qiAFdtbmbbPXLih&X~qPfsmJ@zrMYf7?G)6Ak;Mg4ns4s{qT zjFa4bZSVQxlnd`)aRJ}&UjrpD=);#zLR)RKZBCC<{=5#mADeeh@&gCcTdvkyg1;jd z$70R7%Mkp`1!!}5c>$&GH~UniBoz8MnjD&KlfPT7hJWAFm=dQc7~&)6XxYL8}=gwS?0xSmOd0CCAT0oAqAE4GMFWyjQsgd?lUm))LRhrc7^(sRtom z#uAzBS?=Q|wT}g&qYhU0CTH=R`-8pRDrr4P_iA6y!fuTR#r2hS7cmwIPVAMY6nZhK z3YX$s>q~t23Dzh1^=1$)3cbo?rN_D5lSE0!Kt`q*@;&eCt7EL1^&$r5T2MXsfI`FI z+-8KP4pHCFRgpa^t7T4aZ#cC`zN)gV5fzcuu(2C-a-i{T8MPvv>qO2jXKFIKFZ;S- z#kO0W1j?7X-6Cgr#mC8D@BPMNNN;HNZagelKqDp1(O+LBOTgH~9NP6)EF9tyMWP^d zyby7F4R&NQA@xY)4q_|*t-R*H-`408+cF*)l#WSpF;ieVkl0rP zwUSWXo3Lp7S6S{UfSV&*6-hnGrSdUS8Nq+541xEnEZ+8NyF-uiV*9j?i_e#rR#b61nQZ=FL##ALX`_m(jT_4OjT|N3;R`67QU}N z0xF5RG#(d8zG)x|ryY#%_^!&r+b-E0c309LQ2!9q+CUV%OuR8?9xaejCk-Ea6vEp2 z-59mrxXi~#Frz;exSHc1n^W{domN$8u+HGm{@)Fp|F9*=1%^@_<4_K!5Luu6%51w` zgQfZ%=)$q90aJlOmAF|^APJNsZAtVWePT>|?Q1|>oN2e}%e59B{iF^WV)C?g1BH<} z8KZP1>1ev9JU&9T1Hd>=dUqvq`N^#TJ=W21xKH-Y$U~@H`FkLOmva*uYkRb0isV;7 zRD5NmpTQpIBwDR~&XH35sxJ=(nBBTb*LI8PpiHp)0;x$nx_j3)t#&SfZ6L}Zguj=E=u_rUfUIAMS0h-Nw7w)+S&5_W-Q{c|Toy%v`Y;Y{u)V;oJc>;&EQl0TO z^uYB?jXwJG6c<$?wR&SrQWFfH=cSPRBkZ&hg)BhEKmr(Pv$lX6R>aPjxnZd366z5u zZGMb7<5r=lHxr|K(Bemfc!o&*xR&SbuCa9rb-eId&P^piH+M0(OoM)m8hr9^%x}`! zZ6_^Z$Nd2CruhL$?{bwIFF8xPCRKAKz$dw*?lDYhEtyt9CU?tvV%rzu1XCck`m9M_ z*Ob8Rf5$>?L}Lf2 zAacbwjO`!D^{#zgDxX=O(`8}WosY2IVX?RJW5$9F6v@0uc=u`hjz>~=+(d9Q`gH&xT&h%wLxaL|;a>P!iH-gvHsdQt%$Yj%v zRm#+ugvfZwgQ1Z6C%t!zb+uXtiglRb&RM#ohI(1q%5Fc2ga8Z-(11w0j9A>k?p;W< zsur&3aR;f8zv0d|L|X=J9(kS<cvuBN5mqA!=kv(zk>^8w)Q5Fh zu7Sq0=aXUO1Moy{k(ZFD3nuiw%L>|cS$5M}R0Eab`nl!>MTnK7afVcS=iMa_ydRxU*;P0{)KOcL6eewl*2dgV8e-0;x#N@a+sB7D?Rd) zC;7VL45N#~uyPaR^K;iFN0Ah_CU@P`P~J}V)NZa+JH8Me_PwpBYT)a(kj=dZ!G!i3 zr>R$aQ1!hIV_5*czhGuNftlUd+N25k<3(C}yH`u74fN={vZ>Ut5-h+O;&PU|MeyS5 zu%y>vc+@e}APMH$=QOh%T!4QH@X1kXcfxMP7C={TR>gVm2zHb^ladkn{JC|7Ej5M$H1*p8 zavpp&NDcZ}7U#+yJK0~LRii%=l71keKp$wPXzZ_(lmrViRN3Bcg6Y?Ng1l)WKj6ip z+|EC=CC!Xie|Jv)O3{eQA}^@`TTNoc2o#~E*CZhLOY)j&5Uu@=*BX4}$oL}e$_6<` z&U`XfX%*AK!js38#0T9BRoHa>ix^y}us=tWD zV-O&Iyp0X16O;%+R=QN0SvDIL{n^$AMMX?~uj)lR{r1Rxdi028YMp^{@j{nfd&*K) z*L-jf4q37hrOqge$O{_DZ99*hkgB!zhJLVjQua(eDL*44;I<$JHAJ~{=NNPMWClG0aEpXE$Vo*6M@bv8{<(@)A(N)%k2Gv z`Uz#SwC-$aI#G(wY|O_gbU-tGWjJ{WBg!-Q0CoW;RAP*-@4KxXM7Q?pKI~ z1Cjh6Vw! zDrfcrdjF)j4Y4A_Y_E*9TH3IX81l3Nb}t^Q;rQL7gbFMTChYTpmqx1XBU?5?~`z8F=FU}R$A87^iw??(Fbi1%$cz{9d&bG=CSSi2aoo; zwu_Xx`|9}&%=Ov3p-X#?nm!+=aV4y=pN6(K zKPJBki+wq;*7^$fbdzqtp(cpgcKpdq#gK&B>7OESPvYA~RW-_`^37`(+;tucZxbiO7{=jvaQoBnWTcmDm4L>%S&ea zMgIA!*7m&H&F(yRey7En)MxqB^Zw zV_FSZv`HP{_IsW>e6w_z&?h}icvzn{KBe1j#Ej{!OO>3uRN!*d%O?-qS?0ejOo|^f zlXTg;ViP=QTHCyHdALZYJbONpLjwIp5?Kd0`pqqU8#h5*%GXBSHVzS}fxIwG{{05u z4UE7Y}ymgYDiCgLYl={jnjw)?*LP$fq@`NgC~YNuWiWD$nt zB`93lgMHU>ubLSA#K= zTH9|c`Jy71C^HlpD{6jEexe23%S!F7m)gy%Dejd(itFhsUftlu3eSs8P;7IO<8Cm` z{AS~GRJXo(UjqKXx^Jf-FR$e+DqH3p7L&JcEfrg>Mr*n>a!G#7p?=mkJ$vC;#pQJ^1}^OT?~D zn`_%;J<94zrqrmz@4MN*4Ss2uqro0KFJ{?qFMQuyNS6w7nS87*p!tJdr}kU>Lba=> zrN8waEvN9>;S^&1xDV!y+nyt~w0^!-v*10?+a5r)-(V8Flc|;=Uvw~BbZWJm$r)2 z5I`S6NrNL2D>;o{6rZtr)-9a9`vFUZd<+#P>`>oF{AdfO%yHm#4 z(&kdmW9Gc_;)dSUs<`wxQ|sIyEZl3$lCQ0>y&L_Ft4#C8S%JKmrGL zny&r=xH`A@X+mVvk1#{M@g=%o>ojYe1pReei#m+eS_?L7&;iY_0~*OGaeA6l>XL#P zF~Kasx!|<`ewmFAAm7sx+yS+EaGHZeq4OD9-H|wPfp2-%3JRz$W_vTp*!~`ERtp{9 zb!aA~Y%Cg_pa_5JbgCiz-~7Y>7W~6RsH@>sRk&-UzOs|=H>RkO3{!g5{_PgG<@6+} zu{tjJ@R}QI{zy8)6Bx6oquhhCeFDSFrhfQ@k{}hZ9Z2IvIM>N7zSY_54(6V-&48Ok z%{Tc;(y=PKYa3$y zM7}e?k1!7~@BjnQMU!#Gm_gO0S~9&3ZV3d7dK6t(PAd>MdB6+%8kEXB%{UTA{YSb4J~75>FHT+FCY$+uGM zEGnBWP|~tjJ15MX7a-XTz|nR9epVH&#*)i219LI-egj&20KL@p;tgei+qGpkl z{Kli1IgAz}Q>>6au5Pjq9dtnQ2rL4K$RABupXQ;Ije)GP?&Dt13UEW7J^*q$%ib64 zwl_zD>@>KpN~8E(oaA(OPt_HMa%)9RKp+Yu($>=3*%P3Hzlwngt9hBwD77d)mre5JUTCEHXy&bVdFR!=) zXqnhwUFUkM4psx4HT{~(stc;z%B3}Ct1;n!V-Rth zFXPK^;Oh+NNYq3iWXs7h5f)V3Ymow^%?jlXlOd%cy+MU20236H;hvGy?uT<%*Bd`g zN*9nL1ky&6H4tfU>fKW2L`to;iqL*S4dD|&Z(1B-7WJu~4iXg9nf(l?oR@;1m=?Ab z^GRJkeXU5whkvudtn9MRcH}t2mb2b^sUZtwn1#|NQ_?Up4tHNW_V^N@Z5v)A_2D|o zGo7JKQY#DJcei)p$A-?m2}|nEHcQ*YSiZ&isPgtnK)B)rxa6QO_@r4Lz3nt>%64 zeU@0x6d=H7=SgoHt}c-%W-`$GY|0ktoWUzdZZ06VqbguI?G6LiFE3^OqvaxZIj3nm zGm2RxL&<)xs<7X+PO5r7^j~T4;Xs(A; z13o9oo)mO%8Z=a1!P$|-Tu1*Z8xZ>{{W=>vfKSHAr7GJ2@3j=%;q2Yhx$^ZNTuilM z$e>p|pK<@&fAAGa8ia?@)tKovxH>m@U1I-9w6tfhLjsoLe|g~U=1aGnPmL zM~id)gMmphmIiHG5+Ty<96`sQpfA&NM>)Ephs<5C6%o{4c-vC&BWq$za+1 zjnmlkZ@CspL<)zyxBPuE!2aCm(mxoMq}cH2Art-@$f)Yd|Kj7)uY|~u;se)zD4lpr zfS0e&irdm)nL>b`KJh0hk1=1Modin~782w7=qn_ykasJxv#gfLxp-?9j+LGA=iV?y zBTH6ab-tWAqHb62qHgZdCpJ%+n{pf&q39T(7-ISlf~G9#{y^-RT^D7ZEq8sX^*_U zEdiY)$j5qXNs94#B5oNJRvX0cgQbcT$QhV8(A>-r(?2Z9j6`MLgUzteG!H@?W*(V^ z@~;pM?=OW0hsvP?yrTj4WZ!qYd7({)#2+EE;3Y-*rIuw7($Ya^O3Qe2h!kcxHD%y6 zI{FlLwqxj9M9YN4fm7KMZqqB3l>;qF&mE|b4WHX&cKN)ni=TD&G#fmls(WTU*B3#Ch;{> zrYNk;!RVHn)2m&vX#9@%j~FNprq(FAIQ_*9BD)w9N{N!>G`-X%o`kSH+lbNWn5<65#92GLA#X?QR#fd z8d6xe8 z)Tzpjwrgph6|{_rr*p||1tc?Odn_3`Bo@GZMHSVhTV8E{PyS!muUK( zY{%(uX#Cr#MwJ8_^Qkk}^L8F+&Y;DfuVt$p(Aj}=zXO?_>~K)3h}=LPMcmQW{P+1m zc1lWRtEDLf;~p?lsSdEVygznFg69jZE0umC4InAW7Z}6C2J=4`=xdtDmg)d*k(IS= z+J=WKU}u`K#T~6dY=TGKLgi2h?(|~^=Vjjm>yyo+ZB9xj3hx0pl9rm*3kKWROh617DnYVt|` z{)CtHbIM7?0q%R%fz^~GP4n@p+GC1b?Ux+4x_C{lr%|NFeJD`1G)|#`T7=D|%IBpUG&qM+P17cR`$07TyH}jb;H7?U zM|JvNooSfM%>X@Ew}9Q&VLH_qd%|p;6b1m-q|J>HjsQS2o5AW zowff{oT^D@|DWN2{r~WJ|6O3a14bm`#CWG!G;V}xjBr@@w^?>VKPg4DaMz}|Edtcx z(Z|onpg@Q#LE70+jc;Cf9^r7RT;Nv#<@zL$r%DkgiM{czrzX^ANQbUJ0y;*LRMcn4 z)$m_Cw2;mT*3s%gR%$%I1Bd7GmwWA2t-uZ4c`Rp0K7o1%?0jvw@f9&7f5DiTsz6|w zbE>j;+yZ`_Wy?8?^+t81#n2)gq>}%TsDTZeBm}#=n!5;2i(ZCIQlvcsY7dY7T&jIY zo~eygV8!|tL?7lqTXv}<3aMEfr8)e=FcbO2LVsAx(8rkfOx+I+`db08gottEQAoC% zq=}?nwWnEbQ#uK@0;Z)X+S8J@e>u{52QWIjIfj@?uI;hRCLuUbk`=D&O#KvjyFai^ zBUyT|Sh<=>0j>@o)8d6MBw9q9JA{W7yEkCfKIa0Raj=>E>53p1GPDB3WhY~!M<+_& zV}b0ensvS>&!!_r>Lw{~>|T3z{A>vJ;iDd;T*WKrOa=9zI^BKouf+3o-tV_+&(8H{ zI-R{{(ymW2ArsaNt(N#iNAa6-^DpXToI)(r6)HgyZV}CBzq2cMa@Dxq#t!^51V!gk zr7N+1kf677_X1QEJkh~jN!B+Sc7wXGLFnx-Dues9&RCxs3kC0{#tXT@PO5XGw~x}j zgv5m88c<$T;-kn>B&4cy;TqEizyfi)psFB1$vp5S%GPeA3|3aV9%=s;5gb#&#%+6| z%4gd;x$V)IbVtS)p)FxJU_rz!Lge|Ts5D7sKG}HYAB>E`GMUN-e@bSEGbBVAWa3x5 z+p5tFYY}xb@sdw#10Pr3Hs(!%QV;&|mYa${b?kNly&_M^7FFwRXQ9bZgBkoub)t`i zkcj*&($>jFc_Vq+#Vp}?Or9qiF0K-4Hjrz3f(!fIxlpp{O|*2^`&i|| zZO2*5cQv!a0-kIxwlkkc#b@OfXBrc`fXP@Z$fa#tk#*DM(yl8DDvR~1%$g5B4K-dQ zPvke|>8@ZGUPj$wGIg)e))O?l?XD<}ca3S0o8v zZjSTU%5PdcbanpsCHT#58^`-Va?ZDM8I~2QXfULjx#tK82?TP`Y>g)vd-zRh8BV$+ zEGFo~f8*k+k-br*$WQr{^B`mWH;)=?K|)wIQm$;LHR@?^a)-?zc(%cI5n-QNH4B~2 zujFBgX99OSoHOeeKiCAXU@P6NlXdFHsoD@50-m z9?E}23l1ZnRcw3U6DzM~H}rV2E(X7F@w3Y)k<%HRe^ABecEyQ9(!?|TTt-#@8y(uM zPF|||l*({jLBr7Iss`_lMtdzO9^;43_V%7g&#?7M@B}&_PH^# zT3(o*xlR5{uS8#zAf0RjY8ctMz*GIuNW1p>C5n*#YXw^)K9&`b#H;BJf;C^ajdB`0 z()0ar2xE;WvrN~QwiKvF8tCpTycJsObzxgMEDIW6{^f(^tZTjV*1*s`W-^T{6wEKX zpCDuaou>%KVK8${b!kdWNBbmAKN|HK}uO* zcV{T0L3OpN-n`pF!iMwaHZGmAfB6sTy=&1;i{C$Mv6lCIZ|*WTl?#Gs+dUY zQ2Zt4FY3DY?CWd~hcXI}w&#+HGm6XAE3QzrEL~c&9o*W|G8~yl(}1T_yLv49D8xMd zW@v#Nt^|j4TTHt1Bsam$FRfs$QPc@cK`uOmk(v6z&PV#60UC_->WC6(pQ6^|~b8lsLn?9s1;ezklj9P+0< zjgLKTKU1J)8`M6rUArzK^^xkm+iV((U0j98X`eQ}IKf19Y`x`Mc^$u=N&6tt%f=We z9=RZcJU55w?QL+1<=?9t@o1H4J&Fh;HnXo#p$(a&5vqDAp6cur$%}(_#dDz$I1Sn> z;GkwEJoIIB*}cH68sxdLdM*YT$#z~gEr>W*y}iYRjrO>ij6AvOs_Va>_b*N4l=q%j z=kvx8e=YP0uE=pNc-c1Ik$%ja=QQ~C|tA&V^(w5sd+dBY=*h}7HSKJ=FF2$fd@hG)vG zj$EXJZ9ZClCf3xi{=yI2K~Lj6-nqvXmOgtRzv3f(m=xGDVfk)jtexAIeDzdF2x9DA zM#f+4q4n&{&FZ=EiJV9#k1lK~^yYD`)e);G>&UfiF7X@iD{aJEO z3$CUYnruORylnf6X*ZhL&a<*!WM$=*!%cjqI#o21kwsqo+<5kb&##vy@de?MTbtqG z#g6y>;JS1gWPW@qgJ?ls~ol8H4A_QohVv(EuN69Xs ztJ0qzf<56#KTn^4SK{fCepBWjsZ}cjUI&5Qm-YTR+WV{rP2pdKx{40OM~+85&!`g0 ze;;Q3tI{BaYS;_@V+fa`goLhZwff#8uc0&?%B_h9_zAwJ94QuS_#&(Y@yoo6^Bcd8 zR98>r(!niGfBUoiZR4`&VWy;6p<17> zDbgQ+Y?(1b4hzhFtGzKOY8UL;5M$(|uH99ka!)mJD{V0$7=fp=v@p|3bAOfOeD>7R z9qjZD9_93;@^WV8@bg>F?Is&9!}Rcpmt5KPYDUkI6Wr`sX9tm)~jz6%S3;a;vdOr>~+C) zX&XAh1O!lswD|fJet7mx;RlQctPAjP=!+Y~pR?-eJZaHMJA847SZ?|1=AxiVc;}p? zy2D1{=$HG4iG#JNOFX)bUr1;FNj+!O|c=R2pj{?Tt>HQAf&ef9Mz={tl5Rurk|T3KD= ztLtv#xYg4qYP4(9-@yG6vkNm(;o)JQ#s!y<4i1`))ncLFD?eW(Kl;TSZ|}GvB(!OX z^i&J{`@BM6l{y>nSJTV60KZZv3CnkqWF0|xI+^>`pJU&6iA$yM%CGtcgLkI_cr zH#pBZ^#5LJwJ~9+@wa!=Y^7{(Dj*8OxK%l2EdTBpQ2(l0;I-I$0*fSVH#f3X!pI zLs_#M8L~ztyJu|KX2`yb!7$Sp#(SgZ`M%HZINsy<{qy_hcf9ilj+yJe@AE#d^FFW7 z=W|}p>CQaoXXl_FTS|oZ_z8*P*MG!Av#uIk;}CCBl#T}ly4cB$g@pSUYgK>A!B5|> z*r+$X*R74<-;i6_Z#0)h3q!dE}7!C^qBS=1|H=mkIkb^8peK>RcqrSniS10_e(jZeI%O% z%Qj6&`4M#3Kuda4m$1g6>I>4A=vx_ON^PGq5*r($J#_24FJb(gs?MHWqOIS&-K(yc z&Zz7mnl90?0CDRcy81QO1&+*_c%|+8_q^d*K`Mf)Gs5yw z;=JLwoYn(Yyw8hUn!}AqMNqDD*PiOo{v!8i50&G{CjPbwv|{9)ms$q@?{GP?bPURW_@