From 4c5a9977779b350a9fb5bd59d70f6becb99aa1b0 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 30 Aug 2026 20:47:30 +0200 Subject: [PATCH 1/4] feat(governance): add the cross-app command seam for governance bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decidiq#874 made GovernanceBody able to hold a Dutch bezwaaradviescommissie and added a REST write path for it. That path is the door for EXTERNAL callers. An in-process app-to-app command cannot use it: ADR-041 says a cross-app command travels as a typed event (gate-27 enforces it), and an in-process HTTP call to our own instance has no session, so ApiController::write() refuses it — which is exactly the state a background migration runs in. So this adds the missing door, copying the DecisionRequestedEvent shape: - GovernanceBodyRequestedEvent / GovernanceBodyCreatedEvent - GovernanceBodyCommandService — the idempotent upsert plus the roster fan-out to Person + Membership - GovernanceBodyRequestedListener, registered beside registerDecisionEvents - sourceApp + externalReference on GovernanceBody, additively Every write is preceded by a resolve. The body resolves on (sourceApp, externalReference), a Person on nextcloudUserId, a Membership on its (person, governanceBody) pair, so a re-run of a consuming migration updates rather than minting a second of each. The body is saved and its id read BEFORE the first membership write, so a crash mid-fan-out leaves a body the next run completes instead of orphans pointing at nothing. `active` is refused when absent rather than defaulted to true: it is the one field the consuming app throws on, and a silent default would route objections to a disbanded committee with nothing erroring. Every idempotency test calls the seam TWICE and counts rows. A test that calls it once sees a body either way and cannot tell an idempotent write from a duplicating one. Mutation-checked: making findBody() always miss, making the Person lookup always miss, and defaulting `active` to true each turn the suite red. ApprovalRouteStore is renamed RegisterObjectStore. It was already generic (save/findAll/normalise over decidiq's register) and this change is its second consumer; a second copy would have been the "second store that drifts" hazard in miniature. Unblocks dossiq's migrate-committees-to-decidiq, which is BLOCKED on precisely this seam and asks nothing else of decidiq. Verified: 1241 unit tests pass (22 skipped, pre-existing), PHPCS/PHPMD/PHPStan/ Psalm clean on every changed file, and all 73 applicable hydra gates green including gate-27 no-phantom-cross-app-rpc. --- .../Registrar/DomainServiceRegistrar.php | 13 + lib/Event/GovernanceBodyCreatedEvent.php | 121 +++++ lib/Event/GovernanceBodyRequestedEvent.php | 306 ++++++++++++ .../GovernanceBodyRequestedListener.php | 131 +++++ lib/Service/ApprovalRouteService.php | 4 +- lib/Service/GovernanceBodyCommandService.php | 415 ++++++++++++++++ ...RouteStore.php => RegisterObjectStore.php} | 19 +- .../register.d/71-governance-body-events.json | 26 + .../governance-body-events/proposal.md | 107 +++++ .../specs/governance-body-events/spec.md | 117 +++++ .../changes/governance-body-events/tasks.md | 43 ++ .../GovernanceBodyRequestedListenerTest.php | 205 ++++++++ .../Unit/Service/ApprovalRouteServiceTest.php | 4 +- .../GovernanceBodyCommandServiceTest.php | 453 ++++++++++++++++++ 14 files changed, 1953 insertions(+), 11 deletions(-) create mode 100644 lib/Event/GovernanceBodyCreatedEvent.php create mode 100644 lib/Event/GovernanceBodyRequestedEvent.php create mode 100644 lib/Listener/GovernanceBodyRequestedListener.php create mode 100644 lib/Service/GovernanceBodyCommandService.php rename lib/Service/{ApprovalRouteStore.php => RegisterObjectStore.php} (84%) create mode 100644 lib/Settings/register.d/71-governance-body-events.json create mode 100644 openspec/changes/governance-body-events/proposal.md create mode 100644 openspec/changes/governance-body-events/specs/governance-body-events/spec.md create mode 100644 openspec/changes/governance-body-events/tasks.md create mode 100644 tests/Unit/Listener/GovernanceBodyRequestedListenerTest.php create mode 100644 tests/Unit/Service/GovernanceBodyCommandServiceTest.php diff --git a/lib/AppInfo/Registrar/DomainServiceRegistrar.php b/lib/AppInfo/Registrar/DomainServiceRegistrar.php index 94c346b97..3fad981fc 100644 --- a/lib/AppInfo/Registrar/DomainServiceRegistrar.php +++ b/lib/AppInfo/Registrar/DomainServiceRegistrar.php @@ -33,7 +33,9 @@ namespace OCA\Decidiq\AppInfo\Registrar; use OCA\Decidiq\Event\DecisionRequestedEvent; +use OCA\Decidiq\Event\GovernanceBodyRequestedEvent; use OCA\Decidiq\Listener\DecisionRequestedListener; +use OCA\Decidiq\Listener\GovernanceBodyRequestedListener; use OCA\Decidiq\Mcp\DecidiqToolProvider; use OCA\Decidiq\Service\EIDASSignatureService; use OCA\Decidiq\Service\IEIDASSignatureService; @@ -100,6 +102,17 @@ private function registerDecisionEvents(IRegistrationContext $context): void { listener: DecisionRequestedListener::class ); + // The same request/response-over-the-bus shape for governance bodies. + // ADR-041: a cross-app COMMAND travels as a typed event. The REST write + // path on ApiController is the door for EXTERNAL callers; an in-process + // call to our own instance has no session to authenticate with, so it + // would be refused by ApiController::write(). Specified by + // openspec/changes/governance-body-events/specs/governance-body-events/spec.md. + $context->registerEventListener( + event: GovernanceBodyRequestedEvent::class, + listener: GovernanceBodyRequestedListener::class + ); + }//end registerDecisionEvents() /** diff --git a/lib/Event/GovernanceBodyCreatedEvent.php b/lib/Event/GovernanceBodyCreatedEvent.php new file mode 100644 index 000000000..d6e910b3e --- /dev/null +++ b/lib/Event/GovernanceBodyCreatedEvent.php @@ -0,0 +1,121 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Event; + +use OCP\EventDispatcher\Event; + +/** + * Conclusion event for a cross-app governance-body command. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class GovernanceBodyCreatedEvent extends Event { + + /** + * Construct the conclusion event. + * + * @param string $governanceBodyId The id Decidiq created or matched + * @param string $sourceApp App id of the producer the command came from + * @param string $externalReference The producer's own id for the originating record + * @param boolean $created True when this command minted the body, false when one matched + * @param string $correlationId Correlation id echoed from the request + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function __construct( + private readonly string $governanceBodyId, + private readonly string $sourceApp, + private readonly string $externalReference, + private readonly bool $created, + private readonly string $correlationId = '', + ) { + parent::__construct(); + + }//end __construct() + + /** + * Get the resolved GovernanceBody id. + * + * @return string The id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getGovernanceBodyId(): string { + return $this->governanceBodyId; + + }//end getGovernanceBodyId() + + /** + * Get the producing app id. + * + * @return string The app id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getSourceApp(): string { + return $this->sourceApp; + + }//end getSourceApp() + + /** + * Get the producer's own reference. + * + * @return string The external reference + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getExternalReference(): string { + return $this->externalReference; + + }//end getExternalReference() + + /** + * Whether the body was newly created rather than matched. + * + * @return boolean The created flag + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function isCreated(): bool { + return $this->created; + + }//end isCreated() + + /** + * Get the correlation id echoed from the request. + * + * @return string The correlation id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getCorrelationId(): string { + return $this->correlationId; + + }//end getCorrelationId() + +}//end class diff --git a/lib/Event/GovernanceBodyRequestedEvent.php b/lib/Event/GovernanceBodyRequestedEvent.php new file mode 100644 index 000000000..f4ea9ad31 --- /dev/null +++ b/lib/Event/GovernanceBodyRequestedEvent.php @@ -0,0 +1,306 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Event; + +use OCP\EventDispatcher\Event; + +/** + * Cross-app command event: a consumer app asks Decidiq to raise a GovernanceBody. + * + * All request fields are immutable (constructor-injected getters). Nextcloud + * typed dispatch is synchronous, so the result slots (governanceBodyId, created, + * handled) are written by Decidiq's listener and read by the producer right + * after dispatch — the same request/response-over-the-bus pattern + * DecisionRequestedEvent uses. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class GovernanceBodyRequestedEvent extends Event { + + /** + * The id of the GovernanceBody Decidiq created or matched (result slot). + * + * @var string|null + */ + private ?string $governanceBodyId = null; + + /** + * Whether the body was newly created (false when an existing one matched). + * + * @var boolean + */ + private bool $created = false; + + /** + * Whether Decidiq's listener handled this command (result slot). + * + * @var boolean + */ + private bool $handled = false; + + /** + * Construct the command event. + * + * @param string $sourceApp App id of the producer (e.g. dossiq) + * @param string $externalReference The producer's own id for the originating record + * @param string $name Body name + * @param string $bodyType GovernanceBody bodyType (e.g. advisory-body) + * @param string $domain Governance domain preset + * @param boolean $active Whether the body may be assigned new work. Stated, never defaulted + * @param array $attributes Further body fields (quorum, jurisdiction, statutoryBasis, termStart, termEnd, parentBody) + * @param array> $members Roster entries of {uid, role, external, label} + * @param string $actorId Nextcloud UID on whose behalf the command runs + * @param string $correlationId Correlation id echoed on GovernanceBodyCreatedEvent + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) This parameter list is a + * PUBLISHED CROSS-APP CONTRACT, not an internal signature. A consumer app + * constructs the event POSITIONALLY through a class-string so it stays + * installable without Decidiq — the same constraint DecisionRequestedEvent + * documents. Collapsing these into an array would move the contract from the + * signature into an undocumented key set. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function __construct( + private readonly string $sourceApp, + private readonly string $externalReference, + private readonly string $name, + private readonly string $bodyType, + private readonly string $domain, + private readonly bool $active, + private readonly array $attributes = [], + private readonly array $members = [], + private readonly string $actorId = '', + private readonly string $correlationId = '', + ) { + parent::__construct(); + + }//end __construct() + + /** + * Get the producing app id. + * + * @return string The app id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getSourceApp(): string { + return $this->sourceApp; + + }//end getSourceApp() + + /** + * Get the producer's own reference for the originating record. + * + * @return string The external reference + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getExternalReference(): string { + return $this->externalReference; + + }//end getExternalReference() + + /** + * Get the body name. + * + * @return string The name + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getName(): string { + return $this->name; + + }//end getName() + + /** + * Get the body type. + * + * @return string The bodyType + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getBodyType(): string { + return $this->bodyType; + + }//end getBodyType() + + /** + * Get the governance domain. + * + * @return string The domain + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getDomain(): string { + return $this->domain; + + }//end getDomain() + + /** + * Whether the body may be assigned new work. + * + * @return boolean The active flag + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function isActive(): bool { + return $this->active; + + }//end isActive() + + /** + * Get the further body fields. + * + * @return array The attribute map + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getAttributes(): array { + return $this->attributes; + + }//end getAttributes() + + /** + * Get the roster entries. + * + * @return array> The members + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getMembers(): array { + return $this->members; + + }//end getMembers() + + /** + * Get the acting Nextcloud UID. + * + * @return string The actor id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getActorId(): string { + return $this->actorId; + + }//end getActorId() + + /** + * Get the correlation id. + * + * @return string The correlation id + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getCorrelationId(): string { + return $this->correlationId; + + }//end getCorrelationId() + + /** + * Get the resolved GovernanceBody id. + * + * @return string The id, or an empty string when unhandled + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function getGovernanceBodyId(): string { + return ($this->governanceBodyId ?? ''); + + }//end getGovernanceBodyId() + + /** + * Record the resolved GovernanceBody id (result slot). + * + * @param string $governanceBodyId The id Decidiq created or matched + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function setGovernanceBodyId(string $governanceBodyId): void { + $this->governanceBodyId = $governanceBodyId; + + }//end setGovernanceBodyId() + + /** + * Whether the body was newly created rather than matched. + * + * @return boolean The created flag + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function isCreated(): bool { + return $this->created; + + }//end isCreated() + + /** + * Record whether the body was newly created (result slot). + * + * @param boolean $created True when this command minted the body + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function setCreated(bool $created): void { + $this->created = $created; + + }//end setCreated() + + /** + * Whether Decidiq handled the command. + * + * @return boolean The handled flag + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function isHandled(): bool { + return $this->handled; + + }//end isHandled() + + /** + * Record that Decidiq handled the command (result slot). + * + * @param boolean $handled True when the command was applied + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function setHandled(bool $handled): void { + $this->handled = $handled; + + }//end setHandled() + +}//end class diff --git a/lib/Listener/GovernanceBodyRequestedListener.php b/lib/Listener/GovernanceBodyRequestedListener.php new file mode 100644 index 000000000..911ee672b --- /dev/null +++ b/lib/Listener/GovernanceBodyRequestedListener.php @@ -0,0 +1,131 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Listener; + +use OCA\Decidiq\Event\GovernanceBodyCreatedEvent; +use OCA\Decidiq\Event\GovernanceBodyRequestedEvent; +use OCA\Decidiq\Service\GovernanceBodyCommandService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Handles GovernanceBodyRequestedEvent by delegating to the command service. + * + * @implements IEventListener + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class GovernanceBodyRequestedListener implements IEventListener { + + /** + * Constructor. + * + * @param GovernanceBodyCommandService $commandService The idempotent upsert engine. + * @param IEventDispatcher $dispatcher Dispatcher for the conclusion event. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly GovernanceBodyCommandService $commandService, + private readonly IEventDispatcher $dispatcher, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Handle a GovernanceBodyRequestedEvent. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function handle(Event $event): void { + if ($event instanceof GovernanceBodyRequestedEvent === false) { + return; + } + + try { + $result = $this->commandService->upsert( + sourceApp: $event->getSourceApp(), + externalReference: $event->getExternalReference(), + body: ($event->getAttributes() + [ + 'name' => $event->getName(), + 'bodyType' => $event->getBodyType(), + 'domain' => $event->getDomain(), + 'active' => $event->isActive(), + ]), + members: $event->getMembers(), + ); + } catch (Throwable $e) { + // The producer sees isHandled() === false and decides what to do. + // Nothing is rethrown: an exception out of handle() aborts the whole + // dispatch, so an unrelated listener on the same event would stop + // running because this one failed. + $this->logger->error( + 'Decidiq: GovernanceBodyRequestedEvent not handled', + [ + 'sourceApp' => $event->getSourceApp(), + 'externalReference' => $event->getExternalReference(), + 'exception' => $e, + ] + ); + return; + }//end try + + $event->setGovernanceBodyId($result['id']); + $event->setCreated($result['created']); + $event->setHandled(true); + + $this->logger->info( + 'Decidiq: handled GovernanceBodyRequestedEvent', + [ + 'sourceApp' => $event->getSourceApp(), + 'externalReference' => $event->getExternalReference(), + 'governanceBodyId' => $result['id'], + 'created' => $result['created'], + ] + ); + + $this->dispatcher->dispatchTyped( + new GovernanceBodyCreatedEvent( + governanceBodyId: $result['id'], + sourceApp: $event->getSourceApp(), + externalReference: $event->getExternalReference(), + created: $result['created'], + correlationId: $event->getCorrelationId(), + ) + ); + + }//end handle() + +}//end class diff --git a/lib/Service/ApprovalRouteService.php b/lib/Service/ApprovalRouteService.php index 632f9d4f2..9f088abd9 100644 --- a/lib/Service/ApprovalRouteService.php +++ b/lib/Service/ApprovalRouteService.php @@ -66,10 +66,10 @@ class ApprovalRouteService { /** * Constructor. * - * @param ApprovalRouteStore $store Reads and writes the objects a route is made of. + * @param RegisterObjectStore $store Reads and writes the objects a route is made of. */ public function __construct( - private readonly ApprovalRouteStore $store, + private readonly RegisterObjectStore $store, ) { }//end __construct() diff --git a/lib/Service/GovernanceBodyCommandService.php b/lib/Service/GovernanceBodyCommandService.php new file mode 100644 index 000000000..c1195f81e --- /dev/null +++ b/lib/Service/GovernanceBodyCommandService.php @@ -0,0 +1,415 @@ + + * @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 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Service; + +use RuntimeException; + +/** + * Idempotent upsert of a GovernanceBody plus its Person/Membership roster. + * + * Every write in here is preceded by a resolve. That is the whole design: a + * migration in the producing app is re-runnable by construction, and a partial + * run is completed by the next one rather than duplicated by it. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class GovernanceBodyCommandService { + + /** + * Schema slug of the body. + */ + private const SCHEMA_BODY = 'governance-body'; + + /** + * Schema slug of a natural person. + */ + private const SCHEMA_PERSON = 'person'; + + /** + * Schema slug of a person's seat on a body. + */ + private const SCHEMA_MEMBERSHIP = 'membership'; + + /** + * Body fields a command may set beyond the five it states explicitly. + * + * A closed list, not a passthrough: an open merge would let a producing app + * write `sourceApp` or `externalReference` itself and break the key this + * service resolves on. + * + * @var list + */ + private const ALLOWED_ATTRIBUTES = [ + 'quorum', + 'quorumRule', + 'jurisdiction', + 'statutoryBasis', + 'votingDefault', + 'termStart', + 'termEnd', + 'parentBody', + 'workflowTemplate', + ]; + + /** + * Membership roles this seam accepts, matching Membership.role. + * + * @var list + */ + private const ALLOWED_ROLES = [ + 'chair', + 'vice-chair', + 'secretary', + 'treasurer', + 'member', + 'observer', + ]; + + /** + * Constructor. + * + * @param RegisterObjectStore $store Reads and writes decidiq's register objects. + */ + public function __construct( + private readonly RegisterObjectStore $store, + ) { + }//end __construct() + + /** + * Raise or update a governance body and its roster. + * + * @param string $sourceApp App id of the producer. + * @param string $externalReference The producer's own id for the originating record. + * @param array $body Body fields; MUST carry name, bodyType, domain and active. + * @param array> $members Roster entries of {uid, role, external, label}. + * + * @return array{id: string, created: bool} The resolved id and whether it was minted here. + * + * @throws RuntimeException When the command is incomplete or a write fails. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function upsert( + string $sourceApp, + string $externalReference, + array $body, + array $members = [], + ): array { + if ($sourceApp === '' || $externalReference === '') { + throw new RuntimeException( + 'A governance-body command needs both sourceApp and externalReference: they are the key a re-run resolves on' + ); + } + + $payload = $this->buildBodyPayload( + sourceApp: $sourceApp, + externalReference: $externalReference, + body: $body, + ); + + $existing = $this->findBody(sourceApp: $sourceApp, externalReference: $externalReference); + $created = ($existing === null); + + // The body is saved and its id read BEFORE the first membership write. + // A membership written against an unsaved body points at nothing, and a + // crash between the two then leaves orphans instead of a body the next + // run can complete (REQ-GBE-004). + $stored = $this->store->save( + schema: self::SCHEMA_BODY, + object: $payload, + uuid: $this->idOf(row: ($existing ?? [])), + ); + + $bodyId = $this->idOf(row: $stored); + if ($bodyId === null) { + throw new RuntimeException('OpenRegister returned a governance body with no id'); + } + + $this->syncRoster(bodyId: $bodyId, members: $members); + + return [ + 'id' => $bodyId, + 'created' => $created, + ]; + + }//end upsert() + + /** + * Build the body object to store. + * + * @param string $sourceApp App id of the producer. + * @param string $externalReference The producer's own reference. + * @param array $body The commanded body fields. + * + * @return array The payload. + * + * @throws RuntimeException When a required field is missing. + */ + private function buildBodyPayload(string $sourceApp, string $externalReference, array $body): array { + foreach (['name', 'bodyType', 'domain'] as $required) { + if (($body[$required] ?? '') === '') { + throw new RuntimeException('A governance-body command needs a ' . $required); + } + } + + // `active` decides whether the body may be assigned new work, and the + // consuming app throws on it. Defaulting an absent value to true would + // route objections to a disbanded committee and nothing would error, so + // an omitted `active` is refused instead (REQ-GBE-005). + if (array_key_exists('active', $body) === false || is_bool($body['active']) === false) { + throw new RuntimeException( + 'A governance-body command must state `active` as a boolean: it is never defaulted' + ); + } + + $payload = [ + 'name' => (string)$body['name'], + 'bodyType' => (string)$body['bodyType'], + 'domain' => (string)$body['domain'], + 'active' => $body['active'], + 'sourceApp' => $sourceApp, + 'externalReference' => $externalReference, + ]; + + foreach (self::ALLOWED_ATTRIBUTES as $key) { + if (array_key_exists($key, $body) === true && $body[$key] !== null && $body[$key] !== '') { + $payload[$key] = $body[$key]; + } + } + + return $payload; + + }//end buildBodyPayload() + + /** + * Resolve an existing body by its provenance pair. + * + * @param string $sourceApp App id of the producer. + * @param string $externalReference The producer's own reference. + * + * @return array|null The row, or null when none matches. + */ + private function findBody(string $sourceApp, string $externalReference): ?array { + $rows = $this->store->findAll( + schema: self::SCHEMA_BODY, + filters: [ + 'sourceApp' => $sourceApp, + 'externalReference' => $externalReference, + ], + ); + + // The filter is re-checked in PHP rather than trusted. A filter key the + // store does not recognise is dropped rather than refused, and a dropped + // filter returns EVERY body — which would make this method match the + // first unrelated row and silently overwrite it. + foreach ($rows as $row) { + $matchesApp = ((string)($row['sourceApp'] ?? '') === $sourceApp); + $matchesRef = ((string)($row['externalReference'] ?? '') === $externalReference); + if ($matchesApp === true && $matchesRef === true) { + return $row; + } + } + + return null; + + }//end findBody() + + /** + * Create or update one seat per roster entry. + * + * @param string $bodyId The stored body's id. + * @param array> $members The roster. + * + * @return void + * + * @throws RuntimeException When an entry is unusable. + */ + private function syncRoster(string $bodyId, array $members): void { + foreach ($members as $member) { + $uid = trim((string)($member['uid'] ?? '')); + if ($uid === '') { + throw new RuntimeException('A roster entry needs a uid'); + } + + $role = (string)($member['role'] ?? 'member'); + if (in_array($role, self::ALLOWED_ROLES, true) === false) { + throw new RuntimeException('Unknown membership role: ' . $role); + } + + $personId = $this->resolvePerson(uid: $uid, member: $member); + $this->upsertMembership( + bodyId: $bodyId, + personId: $personId, + role: $role, + member: $member, + ); + } + + }//end syncRoster() + + /** + * Find the Person for a Nextcloud uid, creating one only when absent. + * + * @param string $uid The Nextcloud user id. + * @param array $member The roster entry. + * + * @return string The person's id. + * + * @throws RuntimeException When the person cannot be stored. + */ + private function resolvePerson(string $uid, array $member): string { + $rows = $this->store->findAll( + schema: self::SCHEMA_PERSON, + filters: ['nextcloudUserId' => $uid], + ); + + foreach ($rows as $row) { + if ((string)($row['nextcloudUserId'] ?? '') !== $uid) { + continue; + } + + $id = $this->idOf(row: $row); + if ($id !== null) { + return $id; + } + } + + $name = trim((string)($member['name'] ?? '')); + if ($name === '') { + $name = $uid; + } + + $stored = $this->store->save( + schema: self::SCHEMA_PERSON, + object: [ + 'name' => $name, + 'nextcloudUserId' => $uid, + ], + ); + + $id = $this->idOf(row: $stored); + if ($id === null) { + throw new RuntimeException('OpenRegister returned a person with no id'); + } + + return $id; + + }//end resolvePerson() + + /** + * Create or update the one seat this person holds on this body. + * + * @param string $bodyId The body's id. + * @param string $personId The person's id. + * @param string $role The membership role. + * @param array $member The roster entry. + * + * @return void + */ + private function upsertMembership(string $bodyId, string $personId, string $role, array $member): void { + $rows = $this->store->findAll( + schema: self::SCHEMA_MEMBERSHIP, + filters: [ + 'governanceBody' => $bodyId, + 'person' => $personId, + ], + ); + + $existingId = null; + foreach ($rows as $row) { + $sameBody = ($this->refOf(value: ($row['governanceBody'] ?? null)) === $bodyId); + $samePerson = ($this->refOf(value: ($row['person'] ?? null)) === $personId); + if ($sameBody === true && $samePerson === true) { + $existingId = $this->idOf(row: $row); + break; + } + } + + $object = [ + 'governanceBody' => $bodyId, + 'person' => $personId, + 'role' => $role, + // Awb 7:13(2): the secretary sits from outside the administrative + // organ. The producer states it; an absent value reads as false + // because "not declared external" is the ordinary case. + 'external' => (bool)($member['external'] ?? false), + ]; + + $label = trim((string)($member['label'] ?? '')); + if ($label !== '') { + $object['label'] = $label; + } + + $this->store->save( + schema: self::SCHEMA_MEMBERSHIP, + object: $object, + uuid: $existingId, + ); + + }//end upsertMembership() + + /** + * Read an object's id out of either shape OpenRegister returns. + * + * @param array $row The row. + * + * @return string|null The id, or null when absent. + */ + private function idOf(array $row): ?string { + $id = (string)($row['id'] ?? ($row['@self']['id'] ?? '')); + if ($id === '') { + return null; + } + + return $id; + + }//end idOf() + + /** + * Reduce a relation value to the id it points at. + * + * OpenRegister returns a relation as a bare uuid string, or as the expanded + * object when the read inlined it. Comparing the raw value would miss the + * expanded form and mint a second membership on every re-run. + * + * @param mixed $value The relation value. + * + * @return string The id, or an empty string. + */ + private function refOf(mixed $value): string { + if (is_array($value) === true) { + return (string)($value['id'] ?? ($value['@self']['id'] ?? '')); + } + + return (string)$value; + + }//end refOf() + +}//end class diff --git a/lib/Service/ApprovalRouteStore.php b/lib/Service/RegisterObjectStore.php similarity index 84% rename from lib/Service/ApprovalRouteStore.php rename to lib/Service/RegisterObjectStore.php index bd3edddff..446c35658 100644 --- a/lib/Service/ApprovalRouteStore.php +++ b/lib/Service/RegisterObjectStore.php @@ -1,12 +1,16 @@ getUser()` is null, which is exactly the state a background + migration runs in. + +The pattern already exists in this app and is the one being copied: +`DecisionRequestedEvent` → `DecisionRequestedListener` → +`DecisionIntegrationService::createDecision()`, with the resolved id written +back onto the event instance and `DecisionConcludedEvent` carrying the +correlation home. + +## Affected Projects + +- [x] Project: `decidiq` — this change. Two events, a command service, a + listener, an idempotency key on `GovernanceBody`. +- [ ] Project: `dossiq` — the consumer. `migrate-committees-to-decidiq` + unblocks on this and is not part of it. + +## Scope + +### In Scope + +1. **`GovernanceBody` gains `sourceApp` and `externalReference`**, additively, + in a register.d fragment. This is the idempotency key and the audit trail of + where a body came from. `Decision` already carries exactly this pair for + exactly this reason; a governance body raised by another app needs it for + the same one. +2. **`GovernanceBodyRequestedEvent`** — the inbound command. Body fields plus a + `members[]` roster of `{uid, role, external}`, a `sourceApp`, an + `externalReference`, a `correlationId`, and an `actorId`. Result slots for + `governanceBodyId`, `created` and `handled`, written by the listener and read + by the producer right after dispatch. +3. **`GovernanceBodyCommandService`** — the engine: + - `upsert()` resolves an existing body by `(sourceApp, externalReference)` + BEFORE writing anything, so a re-run updates rather than mints a second. + - The roster fans out to `Person` + `Membership`. A `Person` is resolved by + `nextcloudUserId` (which `67-model-debt-cleanup` already added) and created + only when absent. + - A `Membership` is resolved by `(person, governanceBody)` so a re-run + updates the role rather than adding a second seat. + - **The body is written before the memberships**, so a crash between them + leaves a body with a partial roster that the next run completes, rather + than orphan memberships pointing at nothing. +4. **`GovernanceBodyRequestedListener`** — maps the event onto the service, + writes the result slots, dispatches `GovernanceBodyCreatedEvent`, and never + lets an exception escape into the dispatcher. +5. **Registration** in `DomainServiceRegistrar`, beside `registerDecisionEvents`. + +### Out of Scope + +- **Any dossiq change.** The migration, the read path and the retirement of + dossiq's local schema are dossiq's. +- **A UI.** Bodies raised this way are ordinary `GovernanceBody` rows and appear + in the existing list and detail pages with no new surface. +- **Deleting a body.** The seam creates and updates; retirement is `active: + false` through the normal surface. + +## Risks + +- 🔴 **A fan-out migration is not idempotent by default.** This is the risk the + consuming proposal names, and it is answered here rather than there: + resolution by `(sourceApp, externalReference)` for the body and by + `nextcloudUserId` for the person are what make a re-run safe. Both are + asserted by tests that call the seam TWICE and count rows, because a test + that calls it once cannot tell an idempotent write from a duplicating one. +- 🔴 **`active` is load-bearing on the consumer side.** dossiq's + `AdvisoryCommitteeService` throws "Committee is archived" on it. The event + carries it explicitly and the service never defaults it silently: an absent + `active` is an error, not a `true`. +- ⚠️ **A `Person` matched by `nextcloudUserId` may be a different human with a + recycled uid.** Accepted: NC uids are not reissued in practice, and the + alternative (matching on name) is worse. +- ⚠️ **The listener runs synchronously inside the producer's request.** A slow + fan-out is the producer's latency. Bounded by the roster size, which for an + Awb 7:13 committee is single digits. + +## Status + +Ready. No dependency outside this repo. diff --git a/openspec/changes/governance-body-events/specs/governance-body-events/spec.md b/openspec/changes/governance-body-events/specs/governance-body-events/spec.md new file mode 100644 index 000000000..05c028e5b --- /dev/null +++ b/openspec/changes/governance-body-events/specs/governance-body-events/spec.md @@ -0,0 +1,117 @@ +# governance-body-events Specification + +## Purpose + +An in-process command seam that lets another installed fleet app ask decidiq to +raise a `GovernanceBody` with its roster, and read back the id decidiq gave it. +Per ADR-041 a cross-app command travels as a typed event, not as REST. + +## Requirements + +### Requirement: REQ-GBE-001 A governance body carries where it came from + +`GovernanceBody` SHALL carry `sourceApp` and `externalReference`. Together they +identify the originating record in the producing app, and they are the key the +seam resolves on so a repeated command updates one body rather than minting a +second. + +#### Scenario: The pair is additive + +- **GIVEN** the register fragment +- **WHEN** the register imports +- **THEN** `GovernanceBody` has `sourceApp` and `externalReference` +- **AND** its `required` list is unchanged, so every stored body stays valid +- **AND** a body created through the existing UI leaves both empty + +### Requirement: REQ-GBE-002 The seam is a typed event, dispatched and answered in process + +Decidiq SHALL register a listener for `GovernanceBodyRequestedEvent`. The +listener SHALL write the resolved id, the created flag and the handled flag onto +the dispatched instance, so a producer reads the result immediately after +dispatch. + +#### Scenario: A request raises a body and answers with its id + +- **GIVEN** an installed decidiq and no body for `(dossiq, cmte-1)` +- **WHEN** a producer dispatches `GovernanceBodyRequestedEvent` for it +- **THEN** a `GovernanceBody` exists with the mapped fields +- **AND** the event reports `handled = true`, `created = true`, and a non-empty + `governanceBodyId` + +#### Scenario: A failure leaves the event unhandled and throws nothing + +- **GIVEN** OpenRegister rejects the write +- **WHEN** the event is dispatched +- **THEN** `handled` is false and `governanceBodyId` is empty +- **AND** no exception escapes the dispatcher, so an unrelated listener still runs + +### Requirement: REQ-GBE-003 A repeated command updates, it does not duplicate + +The service SHALL resolve an existing body by `(sourceApp, externalReference)` +before writing. A second command carrying the same pair SHALL update that body +and SHALL NOT create another. The same rule applies to each roster member: a +`Person` resolves by `nextcloudUserId` and a `Membership` by its +`(person, governanceBody)` pair. + +#### Scenario: Dispatching the same command twice creates one body + +- **GIVEN** a command for `(dossiq, cmte-1)` that has already been handled +- **WHEN** the identical command is dispatched again +- **THEN** exactly one `GovernanceBody` exists for that pair +- **AND** the event reports `created = false` with the same id as the first run + +#### Scenario: Dispatching the same command twice creates one seat per member + +- **GIVEN** a command whose roster names `alice` as chair, already handled +- **WHEN** the identical command is dispatched again +- **THEN** exactly one `Person` exists with `nextcloudUserId = alice` +- **AND** exactly one `Membership` links that person to that body + +#### Scenario: A changed role updates the seat rather than adding one + +- **GIVEN** a handled command naming `alice` as `member` +- **WHEN** a command with the same `externalReference` names `alice` as `chair` +- **THEN** her single membership reads `chair` + +### Requirement: REQ-GBE-004 The body is written before its roster + +The service SHALL persist the `GovernanceBody` and obtain its id BEFORE writing +any `Membership`. A membership SHALL never be written against an unsaved body. + +#### Scenario: A crash mid-fan-out leaves a completable body + +- **GIVEN** a roster of three where the second membership write fails +- **WHEN** the command is dispatched +- **THEN** the body exists with the first membership +- **AND** re-dispatching the command completes the roster without duplicating + the first + +### Requirement: REQ-GBE-005 `active` is never defaulted silently + +`active` decides whether a body may be assigned new work, and the consuming app +throws on it. The service SHALL require the command to state it, and SHALL +refuse a command that omits it rather than assuming `true`. + +#### Scenario: An omitted active is refused + +- **WHEN** a command is dispatched with no `active` +- **THEN** the seam refuses it, no body is written, and `handled` is false + +#### Scenario: An archived committee stays archived across a re-run + +- **GIVEN** a handled command that set `active = false` +- **WHEN** the identical command is dispatched again +- **THEN** the body still reads `active = false` + +### Requirement: REQ-GBE-006 The producer learns the outcome by correlation + +After a successful command the listener SHALL dispatch +`GovernanceBodyCreatedEvent` carrying the `correlationId` from the request, the +resulting `governanceBodyId`, and whether the body was created or matched. + +#### Scenario: The conclusion echoes the correlation + +- **GIVEN** a command carrying `correlationId = abc` +- **WHEN** it is handled +- **THEN** a `GovernanceBodyCreatedEvent` is dispatched with `correlationId = + abc` and the same id the request reports diff --git a/openspec/changes/governance-body-events/tasks.md b/openspec/changes/governance-body-events/tasks.md new file mode 100644 index 000000000..6a1311848 --- /dev/null +++ b/openspec/changes/governance-body-events/tasks.md @@ -0,0 +1,43 @@ +# Tasks: governance-body-events + +## Implementation Tasks + +### Task 1: `sourceApp` + `externalReference` on GovernanceBody +- **spec_ref**: `openspec/changes/governance-body-events/specs/governance-body-events/spec.md#requirement-req-gbe-001-a-governance-body-carries-where-it-came-from` +- **files**: `lib/Settings/register.d/71-governance-body-events.json` +- **acceptance_criteria**: + - GIVEN the fragment WHEN the register imports THEN `GovernanceBody` has both properties and no other schema is touched + - GIVEN `GovernanceBody.required` WHEN compared to before THEN it is unchanged +- [x] Implement +- [x] Test + +### Task 2: The two events +- **spec_ref**: `openspec/changes/governance-body-events/specs/governance-body-events/spec.md#requirement-req-gbe-002-the-seam-is-a-typed-event-dispatched-and-answered-in-process` +- **files**: `lib/Event/GovernanceBodyRequestedEvent.php`, `lib/Event/GovernanceBodyCreatedEvent.php` +- **acceptance_criteria**: + - GIVEN the request event WHEN constructed positionally THEN every field reads back, because a consumer app builds it through a class-string and cannot use named arguments safely + - GIVEN the result slots WHEN unset THEN `isHandled()` is false and `getGovernanceBodyId()` is empty +- [x] Implement +- [x] Test + +### Task 3: GovernanceBodyCommandService — idempotent upsert + roster fan-out +- **spec_ref**: `.../spec.md#requirement-req-gbe-003-a-repeated-command-updates-it-does-not-duplicate` (+ REQ-GBE-004/REQ-GBE-005) +- **files**: `lib/Service/GovernanceBodyCommandService.php` +- **acceptance_criteria**: + - GIVEN a command WHEN dispatched twice THEN one body, one Person per uid, one Membership per person — asserted by COUNTING rows after the second call, not by inspecting the first + - GIVEN a member whose role changes WHEN re-dispatched THEN the existing membership is updated, not supplemented + - GIVEN a roster WHEN written THEN the body is saved and its id read BEFORE the first membership write + - GIVEN a command with no `active` WHEN dispatched THEN it is refused and nothing is written + - GIVEN `active = false` WHEN re-dispatched identically THEN the body stays false +- [x] Implement +- [x] Test + +### Task 4: Listener + registration +- **spec_ref**: `.../spec.md#requirement-req-gbe-006-the-producer-learns-the-outcome-by-correlation` +- **files**: `lib/Listener/GovernanceBodyRequestedListener.php`, `lib/AppInfo/Registrar/DomainServiceRegistrar.php` +- **acceptance_criteria**: + - GIVEN a handled command WHEN it completes THEN `GovernanceBodyCreatedEvent` carries the request's correlationId and the same id + - GIVEN the service throws WHEN the event is dispatched THEN `handled` is false and NO exception escapes handle() + - GIVEN an event of another type WHEN passed to handle() THEN it returns without touching anything +- [x] Implement +- [x] Test diff --git a/tests/Unit/Listener/GovernanceBodyRequestedListenerTest.php b/tests/Unit/Listener/GovernanceBodyRequestedListenerTest.php new file mode 100644 index 000000000..741b2db0a --- /dev/null +++ b/tests/Unit/Listener/GovernanceBodyRequestedListenerTest.php @@ -0,0 +1,205 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\Decidiq\Tests\Unit\Listener + * @author Conduction B.V. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @link https://github.com/ConductionNL/decidiq + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Tests\Unit\Listener; + +use OCA\Decidiq\Event\GovernanceBodyCreatedEvent; +use OCA\Decidiq\Event\GovernanceBodyRequestedEvent; +use OCA\Decidiq\Listener\GovernanceBodyRequestedListener; +use OCA\Decidiq\Service\GovernanceBodyCommandService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Covers the governance-body command listener. + * + * The failure test is the load-bearing one. An exception out of handle() aborts + * the whole typed dispatch, so a listener that rethrows takes down every other + * listener on the same event — and the producer, which reads its answer off the + * event instance, would never reach the line where it checks isHandled(). + */ +class GovernanceBodyRequestedListenerTest extends TestCase { + + /** + * A request event. + * + * @return GovernanceBodyRequestedEvent The event. + */ + private function event(): GovernanceBodyRequestedEvent { + return new GovernanceBodyRequestedEvent( + 'dossiq', + 'cmte-1', + 'Bezwaarcommissie sociaal domein', + 'advisory-body', + 'social_domain', + true, + ['statutoryBasis' => 'Awb 7:13'], + [['uid' => 'alice', 'role' => 'chair']], + 'admin', + 'corr-1', + ); + + }//end event() + + /** + * A handled command writes the result slots and announces the conclusion. + * + * @return void + */ + public function testHandledCommandWritesResultSlotsAndAnnounces(): void { + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->method('upsert')->willReturn(['id' => 'gb-9', 'created' => true]); + + $announced = []; + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->method('dispatchTyped')->willReturnCallback( + static function (Event $e) use (&$announced): void { + $announced[] = $e; + } + ); + + $event = $this->event(); + $listener = new GovernanceBodyRequestedListener( + $service, + $dispatcher, + $this->createMock(LoggerInterface::class), + ); + $listener->handle($event); + + $this->assertTrue($event->isHandled()); + $this->assertTrue($event->isCreated()); + $this->assertSame('gb-9', $event->getGovernanceBodyId()); + + $this->assertCount(1, $announced); + $conclusion = $announced[0]; + $this->assertInstanceOf(GovernanceBodyCreatedEvent::class, $conclusion); + $this->assertSame('corr-1', $conclusion->getCorrelationId()); + $this->assertSame('gb-9', $conclusion->getGovernanceBodyId()); + $this->assertSame('dossiq', $conclusion->getSourceApp()); + $this->assertSame('cmte-1', $conclusion->getExternalReference()); + $this->assertTrue($conclusion->isCreated()); + + }//end testHandledCommandWritesResultSlotsAndAnnounces() + + /** + * A failing command leaves the event unhandled and throws nothing. + * + * @return void + */ + public function testFailureLeavesEventUnhandledAndThrowsNothing(): void { + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->method('upsert')->willThrowException(new RuntimeException('register down')); + + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->expects($this->never())->method('dispatchTyped'); + + $event = $this->event(); + $listener = new GovernanceBodyRequestedListener( + $service, + $dispatcher, + $this->createMock(LoggerInterface::class), + ); + + $listener->handle($event); + + $this->assertFalse($event->isHandled()); + $this->assertSame('', $event->getGovernanceBodyId()); + $this->assertFalse($event->isCreated()); + + }//end testFailureLeavesEventUnhandledAndThrowsNothing() + + /** + * A matched body reports created = false. + * + * @return void + */ + public function testMatchedBodyReportsNotCreated(): void { + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->method('upsert')->willReturn(['id' => 'gb-9', 'created' => false]); + + $event = $this->event(); + $listener = new GovernanceBodyRequestedListener( + $service, + $this->createMock(IEventDispatcher::class), + $this->createMock(LoggerInterface::class), + ); + $listener->handle($event); + + $this->assertTrue($event->isHandled()); + $this->assertFalse($event->isCreated()); + + }//end testMatchedBodyReportsNotCreated() + + /** + * An unrelated event passes through untouched. + * + * @return void + */ + public function testUnrelatedEventIsIgnored(): void { + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->expects($this->never())->method('upsert'); + + $listener = new GovernanceBodyRequestedListener( + $service, + $this->createMock(IEventDispatcher::class), + $this->createMock(LoggerInterface::class), + ); + + $listener->handle(new class extends Event { + }); + + $this->addToAssertionCount(1); + + }//end testUnrelatedEventIsIgnored() + + /** + * The four stated fields reach the service alongside the attribute bag. + * + * @return void + */ + public function testStatedFieldsReachTheService(): void { + $seen = []; + $service = $this->createMock(GovernanceBodyCommandService::class); + $service->method('upsert')->willReturnCallback( + static function (string $app, string $ref, array $body, array $members) use (&$seen): array { + $seen = ['app' => $app, 'ref' => $ref, 'body' => $body, 'members' => $members]; + + return ['id' => 'gb-9', 'created' => true]; + } + ); + + $listener = new GovernanceBodyRequestedListener( + $service, + $this->createMock(IEventDispatcher::class), + $this->createMock(LoggerInterface::class), + ); + $listener->handle($this->event()); + + $this->assertSame('dossiq', $seen['app']); + $this->assertSame('cmte-1', $seen['ref']); + $this->assertSame('Bezwaarcommissie sociaal domein', $seen['body']['name']); + $this->assertSame('advisory-body', $seen['body']['bodyType']); + $this->assertSame('social_domain', $seen['body']['domain']); + $this->assertTrue($seen['body']['active']); + $this->assertSame('Awb 7:13', $seen['body']['statutoryBasis']); + $this->assertSame([['uid' => 'alice', 'role' => 'chair']], $seen['members']); + + }//end testStatedFieldsReachTheService() + +}//end class diff --git a/tests/Unit/Service/ApprovalRouteServiceTest.php b/tests/Unit/Service/ApprovalRouteServiceTest.php index a0364f9ec..1a56d55cf 100644 --- a/tests/Unit/Service/ApprovalRouteServiceTest.php +++ b/tests/Unit/Service/ApprovalRouteServiceTest.php @@ -17,7 +17,7 @@ namespace OCA\Decidiq\Tests\Unit\Service; use OCA\Decidiq\Service\ApprovalRouteService; -use OCA\Decidiq\Service\ApprovalRouteStore; +use OCA\Decidiq\Service\RegisterObjectStore; use OCA\OpenRegister\Contract\ObjectEntityInterface; use OCA\OpenRegister\Contract\ObjectServiceInterface; use PHPUnit\Framework\TestCase; @@ -162,7 +162,7 @@ function ( static fn (array $config = []): array => $state->findAll($config) ); - return new ApprovalRouteService(new ApprovalRouteStore($facade)); + return new ApprovalRouteService(new RegisterObjectStore($facade)); } /** diff --git a/tests/Unit/Service/GovernanceBodyCommandServiceTest.php b/tests/Unit/Service/GovernanceBodyCommandServiceTest.php new file mode 100644 index 000000000..363a13b08 --- /dev/null +++ b/tests/Unit/Service/GovernanceBodyCommandServiceTest.php @@ -0,0 +1,453 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\Decidiq\Tests\Unit\Service + * @author Conduction B.V. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @link https://github.com/ConductionNL/decidiq + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\Tests\Unit\Service; + +use OCA\Decidiq\Service\GovernanceBodyCommandService; +use OCA\Decidiq\Service\RegisterObjectStore; +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +/** + * Covers the cross-app governance-body command engine. + * + * Every idempotency assertion here calls the seam TWICE and then COUNTS rows. + * A test that calls it once cannot tell an idempotent write from a duplicating + * one — it sees a body either way — which is exactly how a fan-out migration + * ships looking correct and mints a second Person per member on the re-run. + */ +class GovernanceBodyCommandServiceTest extends TestCase { + + /** + * In-memory OpenRegister stand-in. + * + * @var object + */ + private object $objectService; + + /** + * Build the fake register. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->objectService = new class { + /** + * Stored rows, keyed by schema then uuid. + * + * @var array>> + */ + public array $rows = ['governance-body' => [], 'person' => [], 'membership' => []]; + + /** + * Schemas whose next write must throw, keyed by schema slug. + * + * @var array + */ + public array $failWriteAt = []; + + /** + * Writes seen per schema. + * + * @var array + */ + public array $writes = []; + + /** + * Uuid counter. + * + * @var int + */ + private int $counter = 0; + + /** + * Create or patch a row. + * + * @param array $object The object or patch. + * @param string $register The register. + * @param string $schema The schema. + * @param string|null $uuid The uuid. + * + * @return array The stored row. + */ + public function saveObject(array $object, string $register, string $schema, ?string $uuid = null): array { + $this->writes[$schema] = (($this->writes[$schema] ?? 0) + 1); + if (($this->failWriteAt[$schema] ?? null) === $this->writes[$schema]) { + throw new RuntimeException('simulated write failure on ' . $schema); + } + + if ($uuid === null) { + $this->counter++; + $uuid = $schema . '-' . $this->counter; + $this->rows[$schema][$uuid] = ($object + ['id' => $uuid]); + + return $this->rows[$schema][$uuid]; + } + + $this->rows[$schema][$uuid] = (array_merge(($this->rows[$schema][$uuid] ?? []), $object) + ['id' => $uuid]); + + return $this->rows[$schema][$uuid]; + } + + /** + * Filter the stored rows. + * + * @param array $config The query config. + * + * @return array> The rows. + */ + public function findAll(array $config): array { + $filters = $config['filters']; + $schema = $filters['schema']; + unset($filters['register'], $filters['schema']); + + $out = []; + foreach (($this->rows[$schema] ?? []) as $row) { + $matches = true; + foreach ($filters as $key => $value) { + if (($row[$key] ?? null) !== $value) { + $matches = false; + break; + } + } + + if ($matches === true) { + $out[] = $row; + } + } + + return $out; + } + }; + + }//end setUp() + + /** + * The service under test. + * + * @return GovernanceBodyCommandService The service. + */ + private function service(): GovernanceBodyCommandService { + $state = $this->objectService; + $facade = $this->createMock(ObjectServiceInterface::class); + $facade->method('saveObject')->willReturnCallback( + function ( + array $object, + ?array $extend = [], + string|int|null $register = null, + string|int|null $schema = null, + ?string $uuid = null, + ) use ($state): ObjectEntityInterface { + $row = $state->saveObject($object, (string)$register, (string)$schema, $uuid); + + $entity = $this->createMock(ObjectEntityInterface::class); + $entity->method('jsonSerialize')->willReturn($row); + + return $entity; + } + ); + $facade->method('findAll')->willReturnCallback( + static fn (array $config = []): array => $state->findAll($config) + ); + + return new GovernanceBodyCommandService(new RegisterObjectStore($facade)); + + }//end service() + + /** + * A committee command body. + * + * @param boolean $active The active flag. + * + * @return array The body fields. + */ + private function body(bool $active = true): array { + return [ + 'name' => 'Bezwaarcommissie sociaal domein', + 'bodyType' => 'advisory-body', + 'domain' => 'social_domain', + 'active' => $active, + 'quorum' => 3, + 'statutoryBasis' => 'Awb 7:13', + ]; + + }//end body() + + /** + * A three-seat roster. + * + * @param string $aliceRole Alice's role. + * + * @return array> The roster. + */ + private function roster(string $aliceRole = 'chair'): array { + return [ + ['uid' => 'alice', 'role' => $aliceRole], + ['uid' => 'bob', 'role' => 'member'], + ['uid' => 'carol', 'role' => 'secretary', 'external' => true], + ]; + + }//end roster() + + /** + * Count stored rows of one schema. + * + * @param string $schema The schema slug. + * + * @return int The count. + */ + private function countRows(string $schema): int { + return count($this->objectService->rows[$schema]); + + }//end countRows() + + /** + * REQ-GBE-002: a command raises a body and reports its id. + * + * @return void + */ + public function testCommandRaisesBodyAndReportsId(): void { + $result = $this->service()->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $this->assertTrue($result['created']); + $this->assertNotSame('', $result['id']); + $this->assertSame(1, $this->countRows('governance-body')); + + $stored = $this->objectService->rows['governance-body'][$result['id']]; + $this->assertSame('dossiq', $stored['sourceApp']); + $this->assertSame('cmte-1', $stored['externalReference']); + $this->assertSame('Awb 7:13', $stored['statutoryBasis']); + $this->assertSame(3, $stored['quorum']); + + }//end testCommandRaisesBodyAndReportsId() + + /** + * REQ-GBE-003: a second identical command creates one body, not two. + * + * @return void + */ + public function testSecondIdenticalCommandCreatesOneBody(): void { + $service = $this->service(); + $first = $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + $second = $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $this->assertSame(1, $this->countRows('governance-body')); + $this->assertSame($first['id'], $second['id']); + $this->assertTrue($first['created']); + $this->assertFalse($second['created']); + + }//end testSecondIdenticalCommandCreatesOneBody() + + /** + * REQ-GBE-003: the roster fan-out is idempotent too. + * + * @return void + */ + public function testSecondIdenticalCommandCreatesOneSeatPerMember(): void { + $service = $this->service(); + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $this->assertSame(3, $this->countRows('person')); + $this->assertSame(3, $this->countRows('membership')); + + }//end testSecondIdenticalCommandCreatesOneSeatPerMember() + + /** + * REQ-GBE-003: a changed role updates the seat rather than adding one. + * + * @return void + */ + public function testChangedRoleUpdatesTheSeat(): void { + $service = $this->service(); + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster('member')); + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster('chair')); + + $this->assertSame(3, $this->countRows('membership')); + + $alice = null; + foreach ($this->objectService->rows['person'] as $row) { + if ($row['nextcloudUserId'] === 'alice') { + $alice = $row['id']; + } + } + + $roles = []; + foreach ($this->objectService->rows['membership'] as $row) { + if ($row['person'] === $alice) { + $roles[] = $row['role']; + } + } + + $this->assertSame(['chair'], $roles); + + }//end testChangedRoleUpdatesTheSeat() + + /** + * REQ-GBE-003: a different externalReference is a different body. + * + * @return void + */ + public function testDifferentReferenceIsADifferentBody(): void { + $service = $this->service(); + $service->upsert('dossiq', 'cmte-1', $this->body(), []); + $service->upsert('dossiq', 'cmte-2', $this->body(), []); + + $this->assertSame(2, $this->countRows('governance-body')); + + }//end testDifferentReferenceIsADifferentBody() + + /** + * REQ-GBE-004: a crash mid-fan-out leaves a body the next run completes. + * + * @return void + */ + public function testCrashMidRosterLeavesACompletableBody(): void { + // Writes to `membership`: 1 = alice, 2 = bob. Fail on bob. + $this->objectService->failWriteAt['membership'] = 2; + + $service = $this->service(); + try { + $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + $this->fail('the simulated write failure should have propagated'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('simulated write failure', $e->getMessage()); + } + + $this->assertSame(1, $this->countRows('governance-body'), 'the body is written before the roster'); + $this->assertSame(1, $this->countRows('membership')); + + $this->objectService->failWriteAt = []; + $result = $service->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $this->assertFalse($result['created'], 'the re-run matches the half-written body'); + $this->assertSame(1, $this->countRows('governance-body')); + $this->assertSame(3, $this->countRows('membership')); + $this->assertSame(3, $this->countRows('person')); + + }//end testCrashMidRosterLeavesACompletableBody() + + /** + * REQ-GBE-005: an omitted `active` is refused, not defaulted. + * + * @return void + */ + public function testOmittedActiveIsRefused(): void { + $body = $this->body(); + unset($body['active']); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/never defaulted/'); + + try { + $this->service()->upsert('dossiq', 'cmte-1', $body, $this->roster()); + } finally { + $this->assertSame(0, $this->countRows('governance-body'), 'nothing is written on a refusal'); + } + + }//end testOmittedActiveIsRefused() + + /** + * REQ-GBE-005: an archived committee stays archived across a re-run. + * + * @return void + */ + public function testArchivedBodyStaysArchivedAcrossRerun(): void { + $service = $this->service(); + $first = $service->upsert('dossiq', 'cmte-1', $this->body(active: false), []); + $service->upsert('dossiq', 'cmte-1', $this->body(active: false), []); + + $this->assertFalse($this->objectService->rows['governance-body'][$first['id']]['active']); + + }//end testArchivedBodyStaysArchivedAcrossRerun() + + /** + * A producer cannot overwrite the provenance pair through the attribute bag. + * + * @return void + */ + public function testProducerCannotOverwriteTheProvenancePair(): void { + $body = ($this->body() + ['sourceApp' => 'evil', 'externalReference' => 'other']); + $result = $this->service()->upsert('dossiq', 'cmte-1', $body, []); + + $stored = $this->objectService->rows['governance-body'][$result['id']]; + $this->assertSame('dossiq', $stored['sourceApp']); + $this->assertSame('cmte-1', $stored['externalReference']); + + }//end testProducerCannotOverwriteTheProvenancePair() + + /** + * An unknown membership role is refused rather than stored. + * + * @return void + */ + public function testUnknownRoleIsRefused(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Unknown membership role/'); + + $this->service()->upsert( + 'dossiq', + 'cmte-1', + $this->body(), + [['uid' => 'alice', 'role' => 'grand-vizier']] + ); + + }//end testUnknownRoleIsRefused() + + /** + * A command without its provenance pair is refused. + * + * @return void + */ + public function testMissingProvenanceIsRefused(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/sourceApp and externalReference/'); + + $this->service()->upsert('', 'cmte-1', $this->body(), []); + + }//end testMissingProvenanceIsRefused() + + /** + * Awb 7:13(2): the secretary's external flag survives the fan-out. + * + * @return void + */ + public function testExternalFlagIsCarriedOntoTheMembership(): void { + $this->service()->upsert('dossiq', 'cmte-1', $this->body(), $this->roster()); + + $carol = null; + foreach ($this->objectService->rows['person'] as $row) { + if ($row['nextcloudUserId'] === 'carol') { + $carol = $row['id']; + } + } + + $external = null; + foreach ($this->objectService->rows['membership'] as $row) { + if ($row['person'] === $carol) { + $external = $row['external']; + } + } + + $this->assertTrue($external); + + }//end testExternalFlagIsCarriedOntoTheMembership() + +}//end class From be3f543c18827eb2013b1fdd7d6116026699fe21 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 30 Aug 2026 22:34:04 +0200 Subject: [PATCH 2/4] fix(ci): give the cross-app listeners their own registrar, and catalogue the two new schema titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real CI failures on this branch, both caused by it. phpmd: adding a second event/listener pair took DomainServiceRegistrar to a coupling of 14, over the threshold — measured 0 on development. The registrar's job is registration, so the coupling is inherent; the fix is that the inbound cross-app surface is its own concern. CrossAppEventRegistrar now holds it as one map in one file, which also means adding a command is one line. This extraction was already in the stacked approval-route PR. It belongs here instead: this is the branch where the threshold is first crossed, and a stacked PR must not be what makes its parent green. check:schema-l10n: `Source app` and `External reference` are new schema property titles with no catalogue key, so they would render in English inside an otherwise translated form. Added to en.json and nl.json and rebuilt the js catalogues. The ratchet is back to its 1634 baseline. The third red check, "Conflict markers and PHP syntax", was CANCELLED rather than failed — it re-runs on this push. Verified at CI SCOPE, not just on the changed files: phpmd, phpcs, phpstan and psalm clean across the whole tree, 1241 unit tests pass, and all four npm checks pass. check:manifest needs Ajv resolvable; without it the script silently falls back to a structural lint and reports a PRE-EXISTING page-type finding, which is worth knowing before someone reads that fallback as a regression. --- l10n/en.js | 4 +- l10n/en.json | 4 +- l10n/nl.js | 4 +- l10n/nl.json | 4 +- lib/AppInfo/Application.php | 5 +- .../Registrar/CrossAppEventRegistrar.php | 81 +++++++++++++++++++ .../Registrar/DomainServiceRegistrar.php | 38 --------- 7 files changed, 97 insertions(+), 43 deletions(-) create mode 100644 lib/AppInfo/Registrar/CrossAppEventRegistrar.php diff --git a/l10n/en.js b/l10n/en.js index feb315f5f..3ea37472d 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1738,7 +1738,9 @@ OC.L10N.register( "No meetings": "No meetings", "Previous month": "Previous month", "Table": "Table", - "Today": "Today" + "Today": "Today", + "Source app": "Source app", + "External reference": "External reference" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 0dbd12c4f..b2060ae87 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1737,7 +1737,9 @@ "No meetings": "No meetings", "Previous month": "Previous month", "Table": "Table", - "Today": "Today" + "Today": "Today", + "Source app": "Source app", + "External reference": "External reference" }, "pluralForm": "nplurals=2; plural=(n != 1);" } diff --git a/l10n/nl.js b/l10n/nl.js index ac1d6cdda..15200f2e1 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2037,7 +2037,9 @@ OC.L10N.register( "{n} meeting(s) exceeded the scheduled time": "{n} vergadering(en) overschreden de geplande tijd", "{n} open action items": "{n} openstaande actiepunten", "{responded} of {invited} responded": "{responded} van {invited} gereageerd", - "{states} states, {transitions} transitions": "{states} statussen, {transitions} overgangen" + "{states} states, {transitions} transitions": "{states} statussen, {transitions} overgangen", + "Source app": "Bronapplicatie", + "External reference": "Externe referentie" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 4c25fe819..1168c5bd7 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -2036,7 +2036,9 @@ "{n} meeting(s) exceeded the scheduled time": "{n} vergadering(en) overschreden de geplande tijd", "{n} open action items": "{n} openstaande actiepunten", "{responded} of {invited} responded": "{responded} van {invited} gereageerd", - "{states} states, {transitions} transitions": "{states} statussen, {transitions} overgangen" + "{states} states, {transitions} transitions": "{states} statussen, {transitions} overgangen", + "Source app": "Bronapplicatie", + "External reference": "Externe referentie" }, "plurals": null, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 155b2288b..167739a51 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -24,6 +24,7 @@ namespace OCA\Decidiq\AppInfo; use OCA\Decidiq\AppInfo\Registrar\AppHostRegistrar; +use OCA\Decidiq\AppInfo\Registrar\CrossAppEventRegistrar; use OCA\Decidiq\AppInfo\Registrar\DomainServiceRegistrar; use OCA\Decidiq\AppInfo\Registrar\IntegrationLeafRegistrar; use OCA\Decidiq\AppInfo\Registrar\ObjectListenerRegistrar; @@ -46,7 +47,8 @@ * than accumulating here: * * - {@see AppHostRegistrar} AppHost boilerplate adoption (ADR-040 / ADR-022). - * - {@see DomainServiceRegistrar} decision events, MCP tools, eIDAS, translation. + * - {@see CrossAppEventRegistrar} inbound cross-app command listeners (ADR-041). + * - {@see DomainServiceRegistrar} MCP tools, eIDAS, translation. * - {@see PlatformIntegrationRegistrar} search, object-write guards, dashboard widget. * - {@see IntegrationLeafRegistrar} server-side half of the OR integration leaves (ADR-066). * - {@see ObjectListenerRegistrar} boot()-time filtered object-lifecycle subscriptions. @@ -140,6 +142,7 @@ public function register(IRegistrationContext $context): void { // them. // @spec openspec/changes/migrate-comments-to-talk-leaf/tasks.md#task-2.1. // @spec openspec/specs/user-settings/spec.md + (new CrossAppEventRegistrar())->register(context: $context); (new DomainServiceRegistrar())->register(context: $context); // Board portal Phase 2 services (audit log, conflict of interest, diff --git a/lib/AppInfo/Registrar/CrossAppEventRegistrar.php b/lib/AppInfo/Registrar/CrossAppEventRegistrar.php new file mode 100644 index 000000000..7ca3e3ba9 --- /dev/null +++ b/lib/AppInfo/Registrar/CrossAppEventRegistrar.php @@ -0,0 +1,81 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Decidiq\AppInfo\Registrar; + +use OCA\Decidiq\Event\DecisionRequestedEvent; +use OCA\Decidiq\Event\GovernanceBodyRequestedEvent; +use OCA\Decidiq\Listener\DecisionRequestedListener; +use OCA\Decidiq\Listener\GovernanceBodyRequestedListener; +use OCP\AppFramework\Bootstrap\IRegistrationContext; + +/** + * Registers every inbound cross-app command listener. + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ +class CrossAppEventRegistrar { + + /** + * The command contract, as event class => listener class. + * + * A map rather than four call sites, so adding a command is one line and + * the whole inbound surface reads as one list. + * + * @var array + */ + private const COMMANDS = [ + // Raise a governance Decision for a consumer's object, and conclude it + // back through DecisionConcludedEvent. + DecisionRequestedEvent::class => DecisionRequestedListener::class, + + // Hold a governance body — a committee, a board — with its roster. + GovernanceBodyRequestedEvent::class => GovernanceBodyRequestedListener::class, + ]; + + /** + * Register every command listener. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + * + * @spec openspec/changes/governance-body-events/specs/governance-body-events/spec.md + */ + public function register(IRegistrationContext $context): void { + foreach (self::COMMANDS as $event => $listener) { + $context->registerEventListener(event: $event, listener: $listener); + } + + }//end register() + +}//end class diff --git a/lib/AppInfo/Registrar/DomainServiceRegistrar.php b/lib/AppInfo/Registrar/DomainServiceRegistrar.php index 3fad981fc..8553f57af 100644 --- a/lib/AppInfo/Registrar/DomainServiceRegistrar.php +++ b/lib/AppInfo/Registrar/DomainServiceRegistrar.php @@ -32,10 +32,6 @@ namespace OCA\Decidiq\AppInfo\Registrar; -use OCA\Decidiq\Event\DecisionRequestedEvent; -use OCA\Decidiq\Event\GovernanceBodyRequestedEvent; -use OCA\Decidiq\Listener\DecisionRequestedListener; -use OCA\Decidiq\Listener\GovernanceBodyRequestedListener; use OCA\Decidiq\Mcp\DecidiqToolProvider; use OCA\Decidiq\Service\EIDASSignatureService; use OCA\Decidiq\Service\IEIDASSignatureService; @@ -75,46 +71,12 @@ class DomainServiceRegistrar { * @spec openspec/specs/mcp-tools/spec.md */ public function register(IRegistrationContext $context): void { - $this->registerDecisionEvents(context: $context); $this->registerMcpToolProvider(context: $context); $this->registerEidasBindings(context: $context); $this->registerTranslationAdapter(context: $context); }//end register() - /** - * The event contract for delegated decisions. - * - * Consumer apps dispatch DecisionRequestedEvent (handled here -> - * createDecision) and listen for DecisionConcludedEvent (emitted from - * DecisionLifecycleService). In-process replacement for the broken - * IntegrationService::getLeaf path. - * - * @param IRegistrationContext $context The registration context - * - * @return void - * - * @spec openspec/changes/decidesk-decision-events/specs/decidesk-decision-events/spec.md - */ - private function registerDecisionEvents(IRegistrationContext $context): void { - $context->registerEventListener( - event: DecisionRequestedEvent::class, - listener: DecisionRequestedListener::class - ); - - // The same request/response-over-the-bus shape for governance bodies. - // ADR-041: a cross-app COMMAND travels as a typed event. The REST write - // path on ApiController is the door for EXTERNAL callers; an in-process - // call to our own instance has no session to authenticate with, so it - // would be refused by ApiController::write(). Specified by - // openspec/changes/governance-body-events/specs/governance-body-events/spec.md. - $context->registerEventListener( - event: GovernanceBodyRequestedEvent::class, - listener: GovernanceBodyRequestedListener::class - ); - - }//end registerDecisionEvents() - /** * Register DecidiqToolProvider as the MCP tool provider for the AI Chat Companion. * From dbc84767dac543f5af4a9794a756695cfdab159d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 04:04:55 +0200 Subject: [PATCH 3/4] fix(l10n): catalogue three schema descriptions development left uncovered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge brought in schema strings with no catalogue key, which would render in English inside an otherwise translated form: the tender `awardedTo` and `referenceNumber` descriptions and the ORI `schemaOrgType` one. This is pre-existing debt rather than something this branch introduced — origin/development measures 1637 uncovered against a 1634 baseline on its own, so the check is red there too. Catalogued rather than worked around, which puts the count at 1632 and lets the ratchet come DOWN two. Baseline lowered accordingly, which is what the checker asks for when the count improves. --- l10n/.schema-l10n-baseline.json | 2 +- l10n/en.js | 5 ++++- l10n/en.json | 5 ++++- l10n/nl.js | 5 ++++- l10n/nl.json | 5 ++++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/l10n/.schema-l10n-baseline.json b/l10n/.schema-l10n-baseline.json index b01d4fbd3..d880fe418 100644 --- a/l10n/.schema-l10n-baseline.json +++ b/l10n/.schema-l10n-baseline.json @@ -1,3 +1,3 @@ { - "uncovered": 1634 + "uncovered": 1632 } diff --git a/l10n/en.js b/l10n/en.js index 3ea37472d..f9580524e 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1740,7 +1740,10 @@ OC.L10N.register( "Table": "Table", "Today": "Today", "Source app": "Source app", - "External reference": "External reference" + "External reference": "External reference", + "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.", + "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).", + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: public tender reference/notice number." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index b2060ae87..f7e0b4893 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1739,7 +1739,10 @@ "Table": "Table", "Today": "Today", "Source app": "Source app", - "External reference": "External reference" + "External reference": "External reference", + "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.", + "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).", + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: public tender reference/notice number." }, "pluralForm": "nplurals=2; plural=(n != 1);" } diff --git a/l10n/nl.js b/l10n/nl.js index da3588182..bf14ca9a4 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2043,7 +2043,10 @@ OC.L10N.register( "Which kind of organisation is this for?": "Voor wat voor organisatie is dit?", "Pick the organisation this app runs for and you get a worked example of it: bodies, meetings, decisions and templates that fit. Installing the app plants nothing on its own, so this is the only step that adds data. Choose \"None\" on a production install.": "Kies de organisatie waarvoor deze app draait en je krijgt een uitgewerkt voorbeeld: organen, vergaderingen, besluiten en sjablonen die daarbij passen. De app zet zelf niets klaar, dus dit is de enige stap die gegevens toevoegt. Kies \"Geen\" op een productieomgeving.", "Load the example data": "Laad de voorbeeldgegevens", - "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen." + "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: de winnende partij, vastgelegd zodra het gunningsbesluit is genomen (status=awarded). Decidiq beheert alleen publiceren, beheren en gunnen; het opstellen gebeurt in procest en het inschrijven in pipelinq.", + "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org-annotatie die hoort bij het ORI-type (ChooseAction / Event / CreativeWork).", + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: het openbare aanbestedings- of publicatienummer." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 6995fdd57..b7c13eeec 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -2042,7 +2042,10 @@ "Which kind of organisation is this for?": "Voor wat voor organisatie is dit?", "Pick the organisation this app runs for and you get a worked example of it: bodies, meetings, decisions and templates that fit. Installing the app plants nothing on its own, so this is the only step that adds data. Choose \"None\" on a production install.": "Kies de organisatie waarvoor deze app draait en je krijgt een uitgewerkt voorbeeld: organen, vergaderingen, besluiten en sjablonen die daarbij passen. De app zet zelf niets klaar, dus dit is de enige stap die gegevens toevoegt. Kies \"Geen\" op een productieomgeving.", "Load the example data": "Laad de voorbeeldgegevens", - "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen." + "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", + "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: de winnende partij, vastgelegd zodra het gunningsbesluit is genomen (status=awarded). Decidiq beheert alleen publiceren, beheren en gunnen; het opstellen gebeurt in procest en het inschrijven in pipelinq.", + "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org-annotatie die hoort bij het ORI-type (ChooseAction / Event / CreativeWork).", + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: het openbare aanbestedings- of publicatienummer." }, "plurals": null, "pluralForm": "nplurals=2; plural=(n != 1);" From 32176a944ecd23418d1e71111106652d2d26d0a2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 31 Aug 2026 04:37:33 +0200 Subject: [PATCH 4/4] fix(ci): clear three checks that are red on development itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decidiq's development branch currently fails five Code Quality jobs. Three of them showed up on this PR and none of the three is this branch's doing, so they are fixed here rather than reported as inherited: - Frontend Check (format): tests/e2e/spec-coverage/example-set-setup-step.spec.ts was not prettier-formatted. Formatted. - Hydra Gates (gate-102 manifest-l10n-coverage): six manifest strings from development's example-set setup step had no nl.json key, so they rendered English to a Dutch user. Catalogued and rebuilt; the gate now passes and all 75 applicable gates are green. - Frontend Check (check:l10n-js): nl.js was behind nl.json. The l10n:build above brings them back in sync. The fourth, Integration Tests (Newman), is NOT fixed here and is not mine: one assertion expects `process-templates` to contain the built-in `association-alv` slug and gets an empty list. That is ProcessTemplate SEEDING, which this branch does not touch, and it fails identically on development. Left alone rather than patched blind — a seeding fix belongs with whoever changed the templates, and guessing at it from here would be a change nobody could review against intent. The fifth, E2E Tests (Playwright), is likewise pre-existing on development. --- l10n/en.js | 8 +++++++- l10n/en.json | 8 +++++++- l10n/nl.js | 8 +++++++- l10n/nl.json | 8 +++++++- tests/e2e/spec-coverage/example-set-setup-step.spec.ts | 8 ++------ 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/l10n/en.js b/l10n/en.js index f9580524e..86879498e 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1743,7 +1743,13 @@ OC.L10N.register( "External reference": "External reference", "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.", "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).", - "consultationType=tender: public tender reference/notice number.": "consultationType=tender: public tender reference/notice number." + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: public tender reference/notice number.", + "Association or VvE": "Association or VvE", + "Company board": "Company board", + "Every schema, generated values": "Every schema, generated values", + "Municipality": "Municipality", + "None, I will set this up myself": "None, I will set this up myself", + "Works council": "Works council" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index f7e0b4893..ec7a986ff 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1742,7 +1742,13 @@ "External reference": "External reference", "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.", "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).", - "consultationType=tender: public tender reference/notice number.": "consultationType=tender: public tender reference/notice number." + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: public tender reference/notice number.", + "Association or VvE": "Association or VvE", + "Company board": "Company board", + "Every schema, generated values": "Every schema, generated values", + "Municipality": "Municipality", + "None, I will set this up myself": "None, I will set this up myself", + "Works council": "Works council" }, "pluralForm": "nplurals=2; plural=(n != 1);" } diff --git a/l10n/nl.js b/l10n/nl.js index bf14ca9a4..eb803c05e 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -2046,7 +2046,13 @@ OC.L10N.register( "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: de winnende partij, vastgelegd zodra het gunningsbesluit is genomen (status=awarded). Decidiq beheert alleen publiceren, beheren en gunnen; het opstellen gebeurt in procest en het inschrijven in pipelinq.", "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org-annotatie die hoort bij het ORI-type (ChooseAction / Event / CreativeWork).", - "consultationType=tender: public tender reference/notice number.": "consultationType=tender: het openbare aanbestedings- of publicatienummer." + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: het openbare aanbestedings- of publicatienummer.", + "Association or VvE": "Vereniging of VvE", + "Company board": "Raad van bestuur", + "Every schema, generated values": "Elk schema, gegenereerde waarden", + "Municipality": "Gemeente", + "None, I will set this up myself": "Geen, ik richt dit zelf in", + "Works council": "Ondernemingsraad" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index b7c13eeec..c28f11d4d 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -2045,7 +2045,13 @@ "Loads the set you picked. The data is obviously sample data, it is safe to run more than once, and you can delete it afterwards.": "Laadt de set die je koos. De gegevens zijn herkenbaar voorbeeldgegevens, je kunt dit meer dan een keer uitvoeren en je kunt ze daarna verwijderen.", "consultationType=tender: the winning party recorded when the award decision is made (status=awarded). Decidiq owns publish/manage/award only; authoring is procest and bidding is pipelinq.": "consultationType=tender: de winnende partij, vastgelegd zodra het gunningsbesluit is genomen (status=awarded). Decidiq beheert alleen publiceren, beheren en gunnen; het opstellen gebeurt in procest en het inschrijven in pipelinq.", "schema.org annotation matching the ORI type (ChooseAction / Event / CreativeWork).": "schema.org-annotatie die hoort bij het ORI-type (ChooseAction / Event / CreativeWork).", - "consultationType=tender: public tender reference/notice number.": "consultationType=tender: het openbare aanbestedings- of publicatienummer." + "consultationType=tender: public tender reference/notice number.": "consultationType=tender: het openbare aanbestedings- of publicatienummer.", + "Association or VvE": "Vereniging of VvE", + "Company board": "Raad van bestuur", + "Every schema, generated values": "Elk schema, gegenereerde waarden", + "Municipality": "Gemeente", + "None, I will set this up myself": "Geen, ik richt dit zelf in", + "Works council": "Ondernemingsraad" }, "plurals": null, "pluralForm": "nplurals=2; plural=(n != 1);" diff --git a/tests/e2e/spec-coverage/example-set-setup-step.spec.ts b/tests/e2e/spec-coverage/example-set-setup-step.spec.ts index 63b5eeacb..f6271821a 100644 --- a/tests/e2e/spec-coverage/example-set-setup-step.spec.ts +++ b/tests/e2e/spec-coverage/example-set-setup-step.spec.ts @@ -117,9 +117,7 @@ test.describe('example sets', () => { ) }) - test('setup status offers the sets the app actually ships', async ({ - page, - }) => { + test('setup status offers the sets the app actually ships', async ({ page }) => { const res = await api(page, 'GET', `${BASE}/api/setup/status`) const profiles = res.json?.profiles ?? [] @@ -210,9 +208,7 @@ test.describe('example sets', () => { ).toBe(true) }) - test('re-loading is safe, because the step promises it is', async ({ - page, - }) => { + test('re-loading is safe, because the step promises it is', async ({ page }) => { // The step body tells the operator it is "safe to run more than once". // That sentence is a contract; this asserts the server keeps it rather // than erroring or reporting failure on a second pass.