Skip to content

Commit 70cda73

Browse files
authored
fix(vocabulary): stop anglicising the statutory ZGW and StUF values, and provision the groups the gates name (#1839)
The demo caseload shipped 18 cases the app then refused to save. A case handler opening one and changing only the title got Property 'confidentiality' should be one of: 'openbaar', ... but is 'public' on a field they had not touched. Measured on a rig, reproduced from a clean install. RenameDutchValueDecisions::VALUE_MAP is keyed by property and applied by COLUMN, against no schema, to every OpenRegister shard table. Seventeen of its entries reached four properties whose vocabulary belongs to a standard: confidentiality (case, caseType, document, documentType), vertrouwelijkheidaanduiding (informatieobject, informatieobjecttype), stufMessage.status and zaaksysteemMapping.synchronisationStatus. The map's own docblock had always excluded exactly these. The schemas are the right side, and they are not alone: LoadDefaultZgwMappings declares an IDENTITY valueMapping for the confidentiality pair in both directions, StufMessageHandler writes 'verzonden', StufAuditLog.vue filters on the Dutch four, and StufCaseMappingStore writes 'in_sync'. Only the map disagreed, and not one schema in the merged register declares any of its replacements. The shipped seed data is clean as written: 417 payloads, zero enum violations. It became invalid only when the repair ran. Worse than a refused save: InformatieobjectAccessGuard ranks the Dutch eight and fails CLOSED on a level it cannot rank, so rewriting 'openbaar' to 'public' reclassified the most public documents the app holds as the most secret, and ZgwAuthMiddleware::isConfidentialityAllowed() answered false for every ZGW consumer. The tell that this was a slip: the map covered five of the eight statutory levels. On the rig, caseType came out mixed — 'public' and 'internal' beside an untouched 'zaakvertrouwelijk'. A set is translated whole or not at all. RealignStatutoryVocabulary moves the rows back for instances that already ran it, schema-scoped rather than by column: reversing 'sent' -> 'verzonden' across every status column would repeat the original mistake backwards. Separately, AccessControlGroupsAreProvisionedTest sweeps the group ids the CODE gates access on, which nothing swept before. Seven of the nine were created by nothing: isInGroup() cannot tell a missing group from an empty one, so process mining, the AI audit export, the KCC citizen lookup and the free-form status transition were permanent silent denials for every non-admin. They join ASSIGNED_GROUPS, which skips ids that already exist. Tests, both proven red first: - every value a seed ships, and every value the migration rewrites TO, satisfies the enum its property declares, with a planted control so the sweep cannot pass by no longer looking - every group an isInGroup() gate names is one this app provisions, reading tokenised source so a docblock example is not mistaken for a gate Also fixes two pre-existing reds encountered on development: the new BesluitMigrationService (dossiq#1837) was never allowlisted as a decision writer, and the new repair step needed its install-exemption reason.
1 parent 41d9525 commit 70cda73

8 files changed

Lines changed: 1109 additions & 24 deletions

‎appinfo/info.xml‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,10 @@ Vrij en open source onder de EUPL-1.2-licentie.
411411
`vertrouwelijkheidaanduiding` values (openbaar … zeer_geheim) are
412412
statutory. Translating them would break the mapping the adapter
413413
exists to perform.
414+
415+
That paragraph was true of the INTENT and false of the map for
416+
seventeen values, until dossiq#1841 removed them and
417+
RealignStatutoryVocabulary below put the rows back.
414418
-->
415419
<step>OCA\Dossiq\Repair\RenameDutchValues</step>
416420
<!--
@@ -431,6 +435,27 @@ Vrij en open source onder de EUPL-1.2-licentie.
431435
it, and it is idempotent: a repaired row no longer matches.
432436
-->
433437
<step>OCA\Dossiq\Repair\RealignLhsActorTypeVocabulary</step>
438+
<!--
439+
dossiq#1841, and the same shape as the step above it: the rename
440+
reached a vocabulary that was never its to translate, and this
441+
puts the stored rows back.
442+
443+
Seventeen entries anglicised the ZGW Vertrouwelijkheidaanduiding
444+
(`confidentiality` on case, caseType, document and documentType;
445+
`vertrouwelijkheidaanduiding` on informatieobject and
446+
informatieobjecttype), the StUF message status and the
447+
zaaksysteem synchronisation status. Every replacement was a value
448+
the row's own schema REFUSES, so a case handler editing any demo
449+
case was rejected on a field they had not touched, and
450+
InformatieobjectAccessGuard — which fails closed on a level it
451+
cannot rank — treated every `public` document as the most secret
452+
one the app holds.
453+
454+
Schema-scoped, unlike the rename: reversing by column would put
455+
`sent` -> `verzonden` on schemas where `sent` is correct.
456+
Idempotent — a repaired row no longer matches the filter.
457+
-->
458+
<step>OCA\Dossiq\Repair\RealignStatutoryVocabulary</step>
434459
<!--
435460
Hermiq owns the EU AI Act Art. 14 oversight record now, and
436461
dossiq's own oversight page is retired. This replays the

‎lib/Repair/ProvisionAssignedGroups.php‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,40 @@ class ProvisionAssignedGroups implements IRepairStep {
9191
* case, so routing its two steps to the handlers would be worse than the
9292
* unassigned steps it replaces.
9393
*
94+
* 🔴 THE SECOND BLOCK IS GATES, NOT ASSIGNMENTS, and it was missing.
95+
* `AccessControlGroupsAreProvisionedTest` sweeps the group ids the CODE
96+
* gates access on, and measured 2026-09-05 that seven of the nine were
97+
* created by nothing at all. `IGroupManager::isInGroup()` cannot tell a
98+
* missing group from an empty one — both answer false, with no log line —
99+
* so each of those was a permanent silent denial for every non-admin, and
100+
* the feature behind it read as broken rather than restricted: process
101+
* mining, the AI audit export, the KCC citizen lookup, and the free-form
102+
* status transition.
103+
*
104+
* `procest-admin` keeps the pre-rename spelling deliberately; see the
105+
* FROZEN note on `TransitionAuthorizer::ADMIN_GROUP_ID`. Provisioning it is
106+
* not renaming it.
107+
*
108+
* Creating a group here never disturbs one an organisation already has:
109+
* `run()` skips any id that exists and seeds only the ones it just made, so
110+
* an instance with its own `beheerders` keeps that group and its members
111+
* untouched.
112+
*
94113
* @var array<int, string>
95114
*/
96-
public const ASSIGNED_GROUPS = ['behandelaars', 'bezwaarcommissie'];
115+
public const ASSIGNED_GROUPS = [
116+
// Assigned work by the shipped flows.
117+
'behandelaars',
118+
'bezwaarcommissie',
119+
// Gated on by the access checks in lib/.
120+
'procest-admin',
121+
'beheerders',
122+
'auditors',
123+
'secretariaat',
124+
'controllers',
125+
'kcc',
126+
'klantcontact',
127+
];
97128

98129
/**
99130
* Constructor.
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
<?php
2+
3+
/**
4+
* Dossiq Realign Statutory Vocabulary Repair Step
5+
*
6+
* Moves the values `RenameDutchValues` anglicised out of the ZGW and StUF
7+
* vocabularies back to the spelling their schema declares (dossiq#1841).
8+
*
9+
* WHAT WENT WRONG. `RenameDutchValueDecisions::VALUE_MAP` is keyed by property
10+
* and applied by COLUMN, against no schema at all, to every OpenRegister shard
11+
* table on the instance. Seventeen of its entries reached four properties whose
12+
* vocabulary is a standard's and is therefore Dutch by statute:
13+
* `confidentiality`, `vertrouwelijkheidaanduiding`, `stufMessage.status` and
14+
* `zaaksysteemMapping.synchronisationStatus`. The map's own docblock had always
15+
* excluded exactly these, and the map contradicted it.
16+
*
17+
* The tell that this was a slip and not a decision: the confidentiality map
18+
* covered five of the eight statutory levels, leaving `zaakvertrouwelijk`,
19+
* `vertrouwelijk` and `confidentieel` in Dutch. A set is translated whole or
20+
* not at all.
21+
*
22+
* WHY IT MATTERED. Every rewritten value is one its own schema refuses, so the
23+
* row could no longer be saved — a case handler opening a demo case and
24+
* changing anything got a validation refusal on a field they had not touched.
25+
* Worse for the confidentiality pair: `InformatieobjectAccessGuard` ranks the
26+
* Dutch eight and fails CLOSED on a value it cannot rank, so `openbaar` became
27+
* `public` and every public document was treated as the most secret one this
28+
* app holds, while `ZgwAuthMiddleware::isConfidentialityAllowed()` answered
29+
* false for every ZGW consumer.
30+
*
31+
* The map entries are removed at source, so a fresh instance cannot acquire
32+
* this. This step exists for the instances that already ran them.
33+
*
34+
* @category Repair
35+
* @package OCA\Dossiq\Repair
36+
*
37+
* @author Conduction Development Team <info@conduction.nl>
38+
* @copyright 2026 Conduction B.V.
39+
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
40+
*
41+
* @version GIT: <git-id>
42+
*
43+
* @link https://conduction.nl
44+
*
45+
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
46+
* SPDX-License-Identifier: EUPL-1.2
47+
*/
48+
49+
declare(strict_types=1);
50+
51+
namespace OCA\Dossiq\Repair;
52+
53+
use OCA\Dossiq\Repair\Support\RunsUnderSystemIdentity;
54+
use OCA\Dossiq\Service\SettingsService;
55+
use OCA\Dossiq\Service\Support\SearchesObjects;
56+
use OCP\Migration\IOutput;
57+
use OCP\Migration\IRepairStep;
58+
use Psr\Log\LoggerInterface;
59+
use Throwable;
60+
61+
/**
62+
* Repair step restoring the statutory ZGW and StUF vocabularies.
63+
*
64+
* @spec exclude Corrective data migration for the Dutch-to-English vocabulary change.
65+
*/
66+
class RealignStatutoryVocabulary implements IRepairStep {
67+
use RunsUnderSystemIdentity;
68+
use SearchesObjects;
69+
70+
/**
71+
* The dossiq register slug.
72+
*/
73+
private const REGISTER_SLUG = 'dossiq';
74+
75+
/**
76+
* Schema slug => property => the wrong English value => the declared Dutch one.
77+
*
78+
* 🔴 SCHEMA-SCOPED, UNLIKE THE MIGRATION THAT CAUSED THIS. Reversing by
79+
* column would repeat the original mistake in the other direction: `status`
80+
* is a column on many schemas and `sent` is a perfectly good value on some
81+
* of them, so a blanket `sent` -> `verzonden` would corrupt rows this step
82+
* exists to protect. Every pair below is addressed through its own schema.
83+
*
84+
* @var array<string, array<string, array<string, string>>>
85+
*/
86+
private const REALIGNMENTS = [
87+
'case' => ['confidentiality' => self::CONFIDENTIALITY],
88+
'caseType' => ['confidentiality' => self::CONFIDENTIALITY],
89+
'document' => ['confidentiality' => self::CONFIDENTIALITY],
90+
'documentType' => ['confidentiality' => self::CONFIDENTIALITY],
91+
'informatieobject' => ['vertrouwelijkheidaanduiding' => self::CONFIDENTIALITY],
92+
'informatieobjecttype' => ['vertrouwelijkheidaanduiding' => self::CONFIDENTIALITY],
93+
'stufMessage' => [
94+
'status' => [
95+
'sent' => 'verzonden',
96+
'confirmed' => 'bevestigd',
97+
'error' => 'fout',
98+
'awaiting_retry' => 'wacht_op_retry',
99+
],
100+
],
101+
'zaaksysteemMapping' => [
102+
'synchronisationStatus' => [
103+
'error' => 'fout',
104+
'cancelled' => 'geannuleerd',
105+
'waiting' => 'wacht',
106+
],
107+
],
108+
];
109+
110+
/**
111+
* The ZGW Vertrouwelijkheidaanduiding, restored to the statutory spelling.
112+
*
113+
* Only the five the migration touched appear here. The other three were
114+
* never translated, which is how the slip was spotted.
115+
*
116+
* @var array<string, string>
117+
*/
118+
private const CONFIDENTIALITY = [
119+
'public' => 'openbaar',
120+
'restricted_public' => 'beperkt_openbaar',
121+
'internal' => 'intern',
122+
'secret' => 'geheim',
123+
'top_secret' => 'zeer_geheim',
124+
];
125+
126+
/**
127+
* Constructor.
128+
*
129+
* @param SettingsService $settingsService Resolves OpenRegister's ObjectService.
130+
* @param LoggerInterface $logger Logger.
131+
*
132+
* @return void
133+
*/
134+
public function __construct(
135+
private SettingsService $settingsService,
136+
private LoggerInterface $logger,
137+
) {
138+
}//end __construct()
139+
140+
/**
141+
* Get the name of this repair step.
142+
*
143+
* @return string
144+
*
145+
* @spec exclude Corrective data migration for the Dutch-to-English vocabulary change.
146+
*/
147+
public function getName(): string {
148+
return 'Realign Dossiq statutory ZGW and StUF vocabularies';
149+
}//end getName()
150+
151+
/**
152+
* Move every mis-translated value back.
153+
*
154+
* NEVER throws: an upgrade that dies here leaves the instance worse off
155+
* than a value left in English does.
156+
*
157+
* @param IOutput $output Output interface for progress reporting.
158+
*
159+
* @return void
160+
*
161+
* @spec exclude Corrective data migration for the Dutch-to-English vocabulary change.
162+
*/
163+
public function run(IOutput $output): void {
164+
$objectService = $this->settingsService->getObjectService();
165+
if ($objectService === null) {
166+
$output->info('Dossiq: statutory vocabulary realignment skipped, OpenRegister unavailable.');
167+
return;
168+
}
169+
170+
$repaired = 0;
171+
172+
try {
173+
$this->withSystemIdentity(
174+
objectService: $objectService,
175+
work: function () use ($objectService, &$repaired): void {
176+
$repaired = $this->realignAll(objectService: $objectService);
177+
}
178+
);
179+
} catch (Throwable $e) {
180+
$this->logger->error(
181+
'Dossiq: statutory vocabulary realignment failed',
182+
['exception' => $e->getMessage()]
183+
);
184+
$output->warning('Dossiq: statutory vocabulary realignment could not run: ' . $e->getMessage());
185+
return;
186+
}
187+
188+
if ($repaired === 0) {
189+
$output->info('Dossiq: no rows needed the statutory vocabulary realignment.');
190+
return;
191+
}
192+
193+
$output->info(sprintf('Dossiq: realigned the statutory vocabulary on %d row(s).', $repaired));
194+
}//end run()
195+
196+
/**
197+
* Walk every schema, property and value pair.
198+
*
199+
* @param object $objectService OpenRegister's object service.
200+
*
201+
* @return integer The number of rows rewritten.
202+
*/
203+
private function realignAll(object $objectService): int {
204+
$repaired = 0;
205+
206+
foreach (self::REALIGNMENTS as $schema => $properties) {
207+
foreach ($properties as $property => $values) {
208+
foreach ($values as $wrong => $right) {
209+
$repaired += $this->realign(
210+
objectService: $objectService,
211+
schema: (string)$schema,
212+
property: (string)$property,
213+
wrong: (string)$wrong,
214+
right: $right
215+
);
216+
}
217+
}
218+
}
219+
220+
return $repaired;
221+
}//end realignAll()
222+
223+
/**
224+
* Rewrite one wrong value on one schema's property.
225+
*
226+
* Filters on the WRONG value rather than reading every row, so the step is
227+
* idempotent: a repaired row no longer matches.
228+
*
229+
* @param object $objectService OpenRegister's object service.
230+
* @param string $schema The schema slug.
231+
* @param string $property The property to rewrite.
232+
* @param string $wrong The value the bad rename produced.
233+
* @param string $right The value the schema declares.
234+
*
235+
* @return integer The number of rows rewritten.
236+
*/
237+
private function realign(
238+
object $objectService,
239+
string $schema,
240+
string $property,
241+
string $wrong,
242+
string $right,
243+
): int {
244+
$rows = $this->searchObjectsAsArrays(
245+
objectService: $objectService,
246+
register: self::REGISTER_SLUG,
247+
schema: $schema,
248+
filters: [
249+
$property => $wrong,
250+
'_limit' => 5000,
251+
],
252+
);
253+
254+
$repaired = 0;
255+
foreach ($rows as $row) {
256+
// Defensive: a filter the backend cannot express server-side would
257+
// return everything, and rewriting a `zaakvertrouwelijk` case to
258+
// `openbaar` would be a far worse bug than the one being fixed.
259+
if (($row[$property] ?? null) !== $wrong) {
260+
continue;
261+
}
262+
263+
$row[$property] = $right;
264+
265+
$saved = $this->saveObjectAsArray(
266+
objectService: $objectService,
267+
register: self::REGISTER_SLUG,
268+
schema: $schema,
269+
object: $row,
270+
);
271+
272+
if ($saved !== null) {
273+
$repaired++;
274+
}
275+
}
276+
277+
return $repaired;
278+
}//end realign()
279+
}//end class

0 commit comments

Comments
 (0)