Skip to content

bug: retrofit surfaced 6 integration/transactional defects (StUF/berichtenbox/multi-tenancy/template/AI/appointment) #601

@rubenvdlinde

Description

@rubenvdlinde

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions