Summary
The 2026-05-24/25 retrofit reverse-spec pass surfaced six integration/transactional defects across the StUF, Berichtenbox, multi-tenancy, template-library, AI-audit and appointment-booking features. Several are functional stubs shipped behind a "complete" surface; others are data-integrity / scale risks. All locations confirmed on origin/development.
(a) Berichtenbox — adapter hardcoded to mock, poll job is a logging-only scaffold, threshold hardcoded
Locations:
lib/Service/BerichtenboxService.php, getAdapter(), lines 269-273
lib/BackgroundJob/BerichtenboxReadStatusJob.php, run(), lines 59-64
lib/Service/BerichtenboxService.php, line 195 (7-day unread threshold)
Observed:
private function getAdapter(): BerichtenboxAdapterInterface {
// For MVP, always use mock adapter.
return new MockAdapter(logger: $this->logger);
}
protected function run($argument): void {
$this->logger->info('Procest: Running Berichtenbox read status poll');
// The actual polling happens in BerichtenboxService::pollReadStatus
// This job would iterate unread messages and poll each one.
}
if ($diff >= 7 && $data['status'] !== 'unread_flagged') { // 7 days hardcoded
Impact: Berichtenbox never talks to a real backend (always MockAdapter), the daily read-status job does nothing but log, and the 7-day unread threshold is not configurable. Shipped as if functional.
Fix: Select the adapter from settings; implement real polling in run(); move the threshold to config.
(b) StUF — handlers do not persist to or query OpenRegister
Location: lib/Controller/StufController.php, handleZakLk01() (L181), handleZakLv01() (L253), handleNpsLv01() (L296), handleEdcLk01() (L334).
Observed: Every handler logs and returns a placeholder, e.g.:
// In a full implementation, create/update OpenRegister objects here.
// For now, return a Bv01 confirmation.
$response = $this->messageBuilder->buildBv01(...);
// In a full implementation, query OpenRegister and build zakLa01 response.
// For now, return an empty zakLa01 response.
$body .= '<zkn:antwoord/>';
Impact: zakLk01/edcLk01 (create/update) never persist; zakLv01/npsLv01 (query) always return empty antwoord. The StUF endpoint accepts traffic and returns valid-looking Bv01/empty responses while doing nothing — a functional stub presented as complete.
Fix: Wire create/update to OpenRegister saveObject and queries to OR search, building real zakLa01/npsLa01 responses.
(c) Multi-tenancy — O(n) tenant lookup + silent-null status treated as active
Locations:
lib/Service/TenantService.php, getTenantByGroupId(), lines 117-141 (linear scan findAll(limit: 500))
lib/Service/TenantService.php, getTenantStatus(), lines 267-279 (returns null on Throwable)
lib/Middleware/TenantMiddleware.php, lines 116-117 (null/empty status → not blocked)
Observed:
// Linear scan is acceptable here — tenant count is small (≤100s).
foreach ($mapper->findAll(limit: 500) as $org) {
if (in_array($groupId, $org->getGroups() ?? [], true)) { return $org->jsonSerialize(); }
}
try { return $mapper->findByUuid($tenantId)->getStatus(); }
catch (Throwable $e) { return null; }
$status = ($tenant['status'] ?? null);
if ($status !== null && $status !== '' && $status !== 'active') { /* block */ }
Impact: (1) Every group-to-tenant resolution scans up to 500 Organisations — O(n) per request, caps at 500 and degrades with tenant growth. (2) A lookup failure / missing status returns null, and the middleware only blocks on a non-empty non-active status — so null/empty is indistinguishable from active and the request is allowed. A transient OR error or a status-less org silently fails open.
Fix: Index the group→tenant lookup; make tenant status an explicit enum and treat unknown/unavailable status as a hard fail-closed (block) rather than implicit allow.
(d) Template-library activation is non-transactional → orphans on partial failure
Location: lib/Service/TemplateLibraryService.php, activateTemplate(), lines 158-260.
Observed: Sequential saveObject() calls create the case type, then status types, properties, document types, decision types and role types in separate loops with no transaction/rollback:
$caseType = $objectService->saveObject($register, $caseTypeSchema, $caseTypeData);
...
foreach (($template['statusTypes'] ?? []) as $statusData) { $objectService->saveObject(...); }
foreach (($template['propertyDefinitions'] ?? []) as $propData) { $objectService->saveObject(...); }
// ... documents / decisions / roles
Impact: If any later saveObject fails, the already-created objects (including the case type) are left orphaned with no cleanup, leaving a half-activated template.
Fix: Wrap activation in a transaction, or track created object IDs and compensate (delete) on failure.
(e) AI audit — auditIndex returns a placeholder string
Location: lib/Controller/AiController.php, auditIndex(), lines 274-290.
Observed:
return new JSONResponse([
'success' => true,
'filters' => array_filter($filters),
'message' => 'Audit trail query — implement with OpenRegister object listing',
]);
Impact: The audit-trail query endpoint never lists anything — it returns success: true plus the implementation note. Callers believe the audit index works.
Fix: Implement the OpenRegister object listing with the supplied filters (caseId/type/limit/offset).
(f) Appointment booking — book-in-backend-then-persist leaves orphans on persist failure
Location: lib/Service/AppointmentService.php, bookAppointment(), lines 83-112.
Observed:
// Book in external backend.
$backendResult = $this->getBackend()->bookAppointment($data);
// Store in OpenRegister.
...
$result = $objectService->saveObject((int) $register, (int) $schema, $appointmentData);
Impact: The external backend slot is booked first; if the subsequent saveObject fails, the backend booking is orphaned (no compensating cancel), so the citizen holds a slot Procest has no record of.
Fix: Persist first or add a compensating cancelAppointment on persist failure (saga/compensation), so backend and OR stay consistent.
Source PRs
#581, #582, #586, #587, #588, #600
Summary
The 2026-05-24/25 retrofit reverse-spec pass surfaced six integration/transactional defects across the StUF, Berichtenbox, multi-tenancy, template-library, AI-audit and appointment-booking features. Several are functional stubs shipped behind a "complete" surface; others are data-integrity / scale risks. All locations confirmed on
origin/development.(a) Berichtenbox — adapter hardcoded to mock, poll job is a logging-only scaffold, threshold hardcoded
Locations:
lib/Service/BerichtenboxService.php,getAdapter(), lines 269-273lib/BackgroundJob/BerichtenboxReadStatusJob.php,run(), lines 59-64lib/Service/BerichtenboxService.php, line 195 (7-day unread threshold)Observed:
Impact: Berichtenbox never talks to a real backend (always
MockAdapter), the daily read-status job does nothing but log, and the 7-day unread threshold is not configurable. Shipped as if functional.Fix: Select the adapter from settings; implement real polling in
run(); move the threshold to config.(b) StUF — handlers do not persist to or query OpenRegister
Location:
lib/Controller/StufController.php,handleZakLk01()(L181),handleZakLv01()(L253),handleNpsLv01()(L296),handleEdcLk01()(L334).Observed: Every handler logs and returns a placeholder, e.g.:
Impact: zakLk01/edcLk01 (create/update) never persist; zakLv01/npsLv01 (query) always return empty
antwoord. The StUF endpoint accepts traffic and returns valid-looking Bv01/empty responses while doing nothing — a functional stub presented as complete.Fix: Wire create/update to OpenRegister
saveObjectand queries to OR search, building real zakLa01/npsLa01 responses.(c) Multi-tenancy — O(n) tenant lookup + silent-null status treated as active
Locations:
lib/Service/TenantService.php,getTenantByGroupId(), lines 117-141 (linear scanfindAll(limit: 500))lib/Service/TenantService.php,getTenantStatus(), lines 267-279 (returnsnullonThrowable)lib/Middleware/TenantMiddleware.php, lines 116-117 (null/empty status → not blocked)Observed:
Impact: (1) Every group-to-tenant resolution scans up to 500 Organisations — O(n) per request, caps at 500 and degrades with tenant growth. (2) A lookup failure / missing status returns
null, and the middleware only blocks on a non-empty non-activestatus — sonull/empty is indistinguishable fromactiveand the request is allowed. A transient OR error or a status-less org silently fails open.Fix: Index the group→tenant lookup; make tenant status an explicit enum and treat unknown/unavailable status as a hard fail-closed (block) rather than implicit allow.
(d) Template-library activation is non-transactional → orphans on partial failure
Location:
lib/Service/TemplateLibraryService.php,activateTemplate(), lines 158-260.Observed: Sequential
saveObject()calls create the case type, then status types, properties, document types, decision types and role types in separate loops with no transaction/rollback:Impact: If any later
saveObjectfails, the already-created objects (including the case type) are left orphaned with no cleanup, leaving a half-activated template.Fix: Wrap activation in a transaction, or track created object IDs and compensate (delete) on failure.
(e) AI audit —
auditIndexreturns a placeholder stringLocation:
lib/Controller/AiController.php,auditIndex(), lines 274-290.Observed:
Impact: The audit-trail query endpoint never lists anything — it returns
success: trueplus the implementation note. Callers believe the audit index works.Fix: Implement the OpenRegister object listing with the supplied filters (caseId/type/limit/offset).
(f) Appointment booking — book-in-backend-then-persist leaves orphans on persist failure
Location:
lib/Service/AppointmentService.php,bookAppointment(), lines 83-112.Observed:
Impact: The external backend slot is booked first; if the subsequent
saveObjectfails, the backend booking is orphaned (no compensating cancel), so the citizen holds a slot Procest has no record of.Fix: Persist first or add a compensating
cancelAppointmenton persist failure (saga/compensation), so backend and OR stay consistent.Source PRs
#581, #582, #586, #587, #588, #600