diff --git a/lib/Listener/AnnotationNotificationListener.php b/lib/Listener/AnnotationNotificationListener.php index d9015e828e..e0aef19ccd 100644 --- a/lib/Listener/AnnotationNotificationListener.php +++ b/lib/Listener/AnnotationNotificationListener.php @@ -43,6 +43,8 @@ class AnnotationNotificationListener implements IEventListener * @param AnnotationNotificationDispatcher $dispatcher Dispatcher used to fire notifications. * * @return void + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ public function __construct( private readonly AnnotationNotificationDispatcher $dispatcher @@ -55,6 +57,8 @@ public function __construct( * @param Event $event Inbound dispatcher event. * * @return void + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ public function handle(Event $event): void { @@ -110,6 +114,8 @@ public function handle(Event $event): void * @param Event $event Inbound dispatcher event. * * @return \OCA\OpenRegister\Db\ObjectEntity|null Object instance, or null when none could be derived. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ private function extractObject(Event $event): ?\OCA\OpenRegister\Db\ObjectEntity { diff --git a/lib/Service/Notification/AnnotationNotificationDispatcher.php b/lib/Service/Notification/AnnotationNotificationDispatcher.php index baea31933c..10fba4205f 100644 --- a/lib/Service/Notification/AnnotationNotificationDispatcher.php +++ b/lib/Service/Notification/AnnotationNotificationDispatcher.php @@ -72,6 +72,8 @@ class AnnotationNotificationDispatcher * @param NotificationDispatchLogMapper|null $dispatchLogMapper Optional dispatch-log mapper for idempotency-key dedup. * * @SuppressWarnings(PHPMD.ExcessiveParameterList) DI-injected dependencies. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ public function __construct( private readonly SchemaMapper $schemaMapper, @@ -105,6 +107,8 @@ public function __construct( * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ public function dispatch(ObjectEntity $object, string $trigger, array $context=[]): void { @@ -393,6 +397,8 @@ public function dispatch(ObjectEntity $object, string $trigger, array $context=[ * @return void * * @SuppressWarnings(PHPMD.ExcessiveParameterList) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ private function dispatchBroadcastChannel( string $channel, @@ -470,6 +476,8 @@ private function dispatchBroadcastChannel( * @param array|null $rateLimit Per-rule override block. * * @return bool True when the dispatch may proceed. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-3 */ private function rateLimitAllows(string $ruleId, string $recipient, ?array $rateLimit): bool { @@ -496,6 +504,7 @@ private function rateLimitAllows(string $ruleId, string $recipient, ?array $rate * @return bool True when the dispatch may proceed. * * @spec openspec/changes/notificatie-engine/tasks.md + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-4 */ private function coalesceAllows(string $ruleId, string $recipient, ?array $coalesce): bool { @@ -532,6 +541,8 @@ private function coalesceAllows(string $ruleId, string $recipient, ?array $coale * @return bool True when the dispatch may proceed (claim succeeded * or mapper unavailable); false when a competing * dispatcher already claimed this (slug, key). + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-5 */ private function claimIdempotencyKey(string $slug, string $key): bool { @@ -590,6 +601,8 @@ private function claimIdempotencyKey(string $slug, string $key): bool * @param array $data Pre-fetched object data array. * * @return string The resolved key. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-5 */ private function resolveIdempotencyKey(string $template, ObjectEntity $object, array $data): string { @@ -640,6 +653,7 @@ static function (array $matches) use ($object, $data): string { * @return void * * @spec openspec/changes/notificatie-engine/tasks.md + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ private function recordHistory( string $ruleId, @@ -702,6 +716,7 @@ private function recordHistory( * @return void * * @spec openspec/changes/notificatie-engine/tasks.md + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ private function recordHistoryAcrossChannels( string $ruleId, @@ -938,6 +953,8 @@ private function emitWebhook( * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ private function matches(array $triggerSpec, string $trigger, array $context): bool { @@ -1017,6 +1034,8 @@ private function matches(array $triggerSpec, string $trigger, array $context): b * @return bool True when the value satisfies every declared operator. * * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ private function numericConditionMatches(mixed $value, array $operators): bool { @@ -1053,6 +1072,8 @@ private function numericConditionMatches(mixed $value, array $operators): bool * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-2 */ private function resolveRecipients(array $recipientsSpec, array $data, ?ObjectEntity $object=null, array $context=[]): array { @@ -1187,6 +1208,8 @@ private function resolveRecipients(array $recipientsSpec, array $data, ?ObjectEn * @return array * * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-2 */ private function resolveObjectAclRecipients(ObjectEntity $object, string $permission): array { @@ -1249,6 +1272,8 @@ private function resolveObjectAclRecipients(ObjectEntity $object, string $permis * @param array $context Per-event context passed through to the resolver. * * @return array + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-2 */ private function resolveExpressionRecipients(string $resolverTag, ObjectEntity $object, array $context): array { @@ -1288,6 +1313,8 @@ private function resolveExpressionRecipients(string $resolverTag, ObjectEntity $ * @param string $uid Candidate Nextcloud user identifier. * * @return bool True when the uid corresponds to a real Nextcloud user. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-2 */ private function userExists(string $uid): bool { @@ -1339,6 +1366,8 @@ private function userExists(string $uid): bool * @return array * * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-2 */ private function extractUidsFromRelation(mixed $value): array { @@ -1660,6 +1689,8 @@ private function emitActivity(string $uid, string $objectId, string $name, strin * @param ObjectEntity $object The object whose schema should be looked up. * * @return Schema|null The resolved schema, or null when missing/unresolvable. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ private function loadSchema(ObjectEntity $object): ?Schema { @@ -1681,6 +1712,8 @@ private function loadSchema(ObjectEntity $object): ?Schema * @param Schema $schema The schema whose annotation should be read. * * @return array|null + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-1 */ private function getAnnotation(Schema $schema): ?array { diff --git a/lib/Service/Notification/NotificationCoalescer.php b/lib/Service/Notification/NotificationCoalescer.php index a790f53f25..3c82f0ac1d 100644 --- a/lib/Service/Notification/NotificationCoalescer.php +++ b/lib/Service/Notification/NotificationCoalescer.php @@ -89,6 +89,8 @@ class NotificationCoalescer * @param IAppConfig $appConfig App-config reader for kill switch. * @param LoggerInterface $logger Logger for silenced-dispatch info events. * @param callable|null $timeProvider Optional time source (defaults to time()). + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-4 */ public function __construct( ICacheFactory $cacheFactory, @@ -130,6 +132,8 @@ public function __construct( * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-4 */ public function shouldDispatch(string $ruleId, string $recipient, ?array $perRuleOverride): bool { @@ -218,6 +222,8 @@ public function shouldDispatch(string $ruleId, string $recipient, ?array $perRul * @param string $recipient Recipient identifier. * * @return array{count:int,opened:int}|null Null when no state exists. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-4 */ public function inspect(string $ruleId, string $recipient): ?array { @@ -255,6 +261,8 @@ public function inspect(string $ruleId, string $recipient): ?array * Whether the coalescer is enabled. Defaults to ON. * * @return bool True when the coalescer should run. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-4 */ private function isEnabled(): bool { @@ -274,6 +282,8 @@ private function isEnabled(): bool * @param array $perRuleOverride The rule's `coalesce` config block. * * @return int Window length in seconds (0 = disabled). + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-4 */ private function resolveWindowSeconds(array $perRuleOverride): int { @@ -296,6 +306,8 @@ private function resolveWindowSeconds(array $perRuleOverride): int * @param array $perRuleOverride The rule's `coalesce` config block. * * @return int|null Max events (null = no cap). + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-4 */ private function resolveMaxEvents(array $perRuleOverride): ?int { @@ -319,6 +331,8 @@ private function resolveMaxEvents(array $perRuleOverride): ?int * @param string $recipient Recipient identifier. * * @return string The cache key. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-4 */ private function key(string $ruleId, string $recipient): string { @@ -334,6 +348,8 @@ private function key(string $ruleId, string $recipient): string * @param int $opened Unix timestamp the window opened. * * @return void + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-4 */ private function persist(string $key, int $count, int $opened): void { diff --git a/lib/Service/Notification/RateLimiter.php b/lib/Service/Notification/RateLimiter.php index b8feef3789..b2b845ecc8 100644 --- a/lib/Service/Notification/RateLimiter.php +++ b/lib/Service/Notification/RateLimiter.php @@ -86,6 +86,8 @@ class RateLimiter * @param IAppConfig $appConfig App-config reader for kill switch + defaults. * @param LoggerInterface $logger Logger for dropped-dispatch info events. * @param callable|null $timeProvider Optional time source (defaults to time()). + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-3 */ public function __construct( ICacheFactory $cacheFactory, @@ -126,6 +128,8 @@ public function __construct( * * @SuppressWarnings(PHPMD.CyclomaticComplexity) * @SuppressWarnings(PHPMD.NPathComplexity) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-3 */ public function tryConsume(string $ruleId, string $recipient, ?array $perRuleOverride=null): bool { @@ -198,6 +202,8 @@ public function tryConsume(string $ruleId, string $recipient, ?array $perRuleOve * Whether the limiter is enabled. Defaults to ON. * * @return bool True when the limiter should run. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-3 */ private function isEnabled(): bool { @@ -221,6 +227,8 @@ private function isEnabled(): bool * @return array{0:int,1:int} Tuple of (bucketSize, refillSeconds). * * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-3 */ private function resolveLimits(?array $perRuleOverride): array { @@ -281,6 +289,8 @@ private function resolveLimits(?array $perRuleOverride): array * @param string $recipient Recipient identifier. * * @return string Cache key. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-3 */ private function key(string $ruleId, string $recipient): string { @@ -298,6 +308,8 @@ private function key(string $ruleId, string $recipient): string * @param int $lastRefill Last refill timestamp. * * @return void + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-3 */ private function persist(string $key, float $tokens, int $lastRefill): void { diff --git a/lib/Service/Notification/RecipientResolverInterface.php b/lib/Service/Notification/RecipientResolverInterface.php index 371a0fedc8..20fb2b88ef 100644 --- a/lib/Service/Notification/RecipientResolverInterface.php +++ b/lib/Service/Notification/RecipientResolverInterface.php @@ -44,6 +44,8 @@ interface RecipientResolverInterface * @param array $context Trigger-specific extras (action, from, to, aggregation, ...). * * @return array List of Nextcloud uids. + * + * @spec openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md#task-2 */ public function resolve(ObjectEntity $object, array $context): array; }//end interface diff --git a/openspec/changes/retrofit-2026-05-24-notificatie-engine/proposal.md b/openspec/changes/retrofit-2026-05-24-notificatie-engine/proposal.md new file mode 100644 index 0000000000..b31e01d46b --- /dev/null +++ b/openspec/changes/retrofit-2026-05-24-notificatie-engine/proposal.md @@ -0,0 +1,91 @@ +# Retrofit — notificatie-engine (partial reverse-spec, 5 of N) + +Reverse-engineers 5 net-new REQs from observed behavior in the annotation-driven notification dispatcher and its supporting services. This is a **partial pass** (first 5 REQs / 31 methods of the 88-method cluster) — the remaining 57 methods are deferred to a follow-up pass (see `future-pass:next` in tasks.md). Code already exists — this change retroactively specifies it. + +## Scope of this pass + +Five cohesive REQs covering the annotation-driven dispatch pipeline core: + +- **REQ-101** — Dispatcher pipeline orchestration (schema annotation read → trigger match → per-recipient fan-out → broadcast channels) +- **REQ-102** — Recipient resolution across six recipient kinds (users / field / groups / relation / object-acl / expression) with `userExists()` fail-closed +- **REQ-103** — Token-bucket rate limiting per `(rule, recipient)` with per-rule override and kill switch +- **REQ-104** — Per-`(rule, recipient)` debounce coalescing with `windowSeconds` + `maxEvents` flush +- **REQ-105** — Idempotency-key claim-first dedup with database unique-index serialisation under concurrency + +## Affected code units (31 methods, this pass) + +### REQ-101 — Dispatcher pipeline +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::dispatch` (entry) +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::matches` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::dispatchBroadcastChannel` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::recordHistory` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::recordHistoryAcrossChannels` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::loadSchema` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::getAnnotation` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::numericConditionMatches` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::__construct` +- `lib/Listener/AnnotationNotificationListener.php::handle` +- `lib/Listener/AnnotationNotificationListener.php::extractObject` +- `lib/Listener/AnnotationNotificationListener.php::__construct` + +### REQ-102 — Recipient resolution +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::resolveRecipients` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::resolveObjectAclRecipients` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::resolveExpressionRecipients` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::extractUidsFromRelation` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::userExists` +- `lib/Service/Notification/RecipientResolverInterface.php::resolve` + +### REQ-103 — Token-bucket rate limiting +- `lib/Service/Notification/RateLimiter.php::__construct` +- `lib/Service/Notification/RateLimiter.php::tryConsume` +- `lib/Service/Notification/RateLimiter.php::isEnabled` +- `lib/Service/Notification/RateLimiter.php::resolveLimits` +- `lib/Service/Notification/RateLimiter.php::key` +- `lib/Service/Notification/RateLimiter.php::persist` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::rateLimitAllows` + +### REQ-104 — Burst coalescing +- `lib/Service/Notification/NotificationCoalescer.php::__construct` +- `lib/Service/Notification/NotificationCoalescer.php::shouldDispatch` +- `lib/Service/Notification/NotificationCoalescer.php::inspect` +- `lib/Service/Notification/NotificationCoalescer.php::isEnabled` +- `lib/Service/Notification/NotificationCoalescer.php::resolveWindowSeconds` +- `lib/Service/Notification/NotificationCoalescer.php::resolveMaxEvents` +- `lib/Service/Notification/NotificationCoalescer.php::key` +- `lib/Service/Notification/NotificationCoalescer.php::persist` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::coalesceAllows` + +### REQ-105 — Idempotency-key claim-first dedup +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::claimIdempotencyKey` +- `lib/Service/Notification/AnnotationNotificationDispatcher.php::resolveIdempotencyKey` + +## Deferred to future passes (57 methods) + +The remaining 57 methods sort cleanly into 7 additional REQs that should be drafted in a follow-on `retrofit-2026-05-25-notificatie-engine-pass-2` (or merged into this change once approved): + +- **Channel emit surfaces** (`emitNotification`, `emitEmail`, `emitActivity`, `emitWebhook`, `emitTalk`) — refines existing channel REQ +- **Per-locale subject resolution + interpolation** (`resolveLocalizedSubject`, `resolveUserLocale`, `interpolate`) — refines i18n REQ +- **Organisation pinning** (`organisationGateAllows`) — refines multi-tenant REQ +- **Subscription gating** (`filterBySubscription`, `NotificationSubscriptionsController::*`, `notificationSubscriptions.js::*`, `NotificationSubscriptionToggle.vue::refresh`, `NotificationsSection.vue::save`) — refines user-preference REQ +- **Annotation validator** (`NotificationAnnotationValidator::validate`, `validateOrganisationGate`) — net-new "schema-save validation" REQ +- **Annotation installer** (`NotificationsAnnotationInstaller::*`) — net-new "persistent webhook materialisation" REQ +- **Scheduled + threshold + batch jobs** (`ScheduledNotificationJob::*`, `BatchNotificationJob::run`, `NotificationDigest::*`, `NotificationReadState::*`) — refines batching/scheduling REQ +- **History controller + audit query** (`NotificationHistoryController::*`) — refines audit REQ +- **VNG envelope** (`VngNotificatiesEnvelope::mapAction`) — refines VNG REQ +- **Renderer-side** (`AnnotationNotifier::prepare`, `getName`, `getID`, `__construct`; legacy `Notifier::__construct`, `getName`) — refines INotifier REQ + +## DROP / sibling-cap + +No methods dropped. The legacy `Notifier::__construct` and `Notifier::getName` carry an existing `@spec` tag pointing at the prior archived retrofit (`retrofit-2026-04-28-notificatie-engine`); those will be re-pointed during the future pass that drafts the renderer REQ. + +## Notes & observations + +- The existing `openspec/specs/notificatie-engine/spec.md` is named-requirement style (no REQ-IDs) and was authored as the original aspirational spec. This retrofit adds **REQ-101..REQ-105** as numbered IDs for traceability of code↔spec links without colliding with the named entries above. REQ-IDs start at 101 to leave 001–099 for any future renumbering of the named requirements during a follow-up cleanup. +- **`notificatie-engine#ISO-8601`** is **NOT** a REQ — confirmed: it appears only as a scenario-token referring to ISO 8601 timestamp format at lines 138 and 319 of the spec (the `aanmaakdatum` field in the VNG envelope and the webhook `timestamp` field). The triage flag is correct; no REQ rename or removal is needed in this pass. +- **Security-relevant observation (REQ-102)**: `resolveRecipients()` reads attacker-controlled object-data fields when `kind=field` or `kind=relation`. Every extracted UID is verified via `IUserManager::userExists()` (with a per-request cache that intentionally does NOT cache `\Throwable` results — transient LDAP / DB hiccups must not silently drop notifications for the rest of the request). This is a deliberate fail-closed gate that should be preserved by any future refactor. +- **Concurrency observation (REQ-105)**: the dispatcher claims the dedup row **before** sending. Prior order (check → send → record) had a TOCTOU window where two concurrent dispatchers could both send. The current order trades a "failed send leaves a dedup row that blocks retry within the window" for "no double-send under concurrency". The `Version1Date20260511120000` migration installs the unique `(notification_slug, idempotency_key)` index that serialises the claim. +- **Fail-open observation (REQ-103 / REQ-104)**: the limiter and coalescer both fail open — when the cache backend is missing or a state read throws, dispatch proceeds. This protects against a broken infrastructure layer silencing legitimate notifications. Operators who want a hard gate must monitor the warning logs (`[NotificationRateLimiter] cache backend unavailable`, `[NotificationCoalescer] cache backend unavailable`). +- **Annotation source observation (REQ-101)**: rules are read from `Schema::getConfiguration()['x-openregister-notifications']` — a schema-author-declared block, not a separate `NotificationRule` table as the existing aspirational spec describes. This is a concrete architectural decision the code already encodes; the existing spec's `NotificationRule` entity is not yet implemented and should be reconciled with the schema-annotation approach in a future design pass. + +Source: `/tmp/or-scan/rspec-cluster-notificatie-engine.json` (88 methods, 19 files). See retrofit playbook. diff --git a/openspec/changes/retrofit-2026-05-24-notificatie-engine/specs/notificatie-engine/spec.md b/openspec/changes/retrofit-2026-05-24-notificatie-engine/specs/notificatie-engine/spec.md new file mode 100644 index 0000000000..632b93b87a --- /dev/null +++ b/openspec/changes/retrofit-2026-05-24-notificatie-engine/specs/notificatie-engine/spec.md @@ -0,0 +1,318 @@ +--- +retrofit: true +mode: extend +--- + +# Notificatie Engine — Reverse-Spec Delta (partial, pass 1 of N) + +## Purpose + +This delta extends `openspec/specs/notificatie-engine/spec.md` with five numbered requirements (REQ-101..REQ-105) that codify the observed behaviour of the annotation-driven notification dispatcher and its rate-limit / coalesce / idempotency surrounding services. The existing named requirements remain authoritative for the aspirational surface (the `NotificationRule` entity, multi-channel routing, user preferences, VNG compliance, etc.); this delta narrows in on the concrete pipeline behaviour the code actually encodes today. + +## ADDED Requirements + +### Requirement: REQ-101 — The dispatcher MUST orchestrate notifications through a schema-annotation pipeline + +`AnnotationNotificationDispatcher::dispatch()` MUST read notification rules from the triggering object's schema annotation (`Schema::getConfiguration()['x-openregister-notifications']`), match each rule against the active trigger type, and fan out matching rules to the configured channels. Broadcast channels (`webhook`, `talk`) MUST be dispatched once per rule with the full recipient list in the payload; per-recipient channels (`nc-notification`, `email`, `activity`) MUST be dispatched once per resolved recipient. The dispatcher MUST be invoked from `AnnotationNotificationListener::handle()`, which subscribes to `ObjectCreatedEvent`, `ObjectUpdatedEvent`, and `ObjectTransitionedEvent` and translates each into the matching trigger string. + +#### Scenario: Schema without a notification annotation produces no dispatches +- **GIVEN** an object whose schema's configuration does not declare `x-openregister-notifications` +- **WHEN** `AnnotationNotificationDispatcher::dispatch()` is invoked +- **THEN** the dispatcher MUST return without calling any channel emit method +- **AND** no history row MUST be recorded + +#### Scenario: Rule with non-matching trigger type is skipped +- **GIVEN** a schema declaring a notification with `trigger.type = "created"` +- **AND** the dispatcher is invoked with trigger `"updated"` +- **THEN** the rule MUST NOT fire on any channel + +#### Scenario: Transition trigger matches by action filter +- **GIVEN** a notification with `trigger.type = "transition"` and `trigger.action = "approve"` +- **WHEN** the dispatcher is invoked with trigger `"transition"` and `context.action = "approve"` +- **THEN** the rule MUST fire +- **AND** when invoked with `context.action = "reject"`, the rule MUST NOT fire +- **AND** when `trigger.action` is an array, the rule MUST fire if `context.action` is any element of that array + +#### Scenario: calculatedChange trigger requires both old and new data +- **GIVEN** a notification with `trigger.type = "calculatedChange"` and `trigger.field = "score"` +- **AND** trigger.condition = `{"lt": 0.85}` and trigger.previously = `{"gte": 0.85}` +- **WHEN** the listener dispatches with `_newData.score = 0.80` and `_oldData.score = 0.90` +- **THEN** the rule MUST fire (boundary crossing detected) +- **AND** if either `_newData` or `_oldData` is missing from the context, the rule MUST NOT fire (fail-closed) + +#### Scenario: ObjectUpdatedEvent emits both updated and calculatedChange triggers +- **GIVEN** an `ObjectUpdatedEvent` with both old and new object instances +- **WHEN** `AnnotationNotificationListener::handle()` processes it +- **THEN** the dispatcher MUST be invoked twice — once with trigger `"updated"` and once with trigger `"calculatedChange"` carrying `_newData` / `_oldData` in the context + +#### Scenario: Broadcast channel fires once with recipient list +- **GIVEN** a rule with `channels: ["webhook"]` and three resolved recipients +- **WHEN** the rule fires +- **THEN** `emitWebhook()` MUST be invoked exactly once +- **AND** the JSON payload MUST contain the full recipient UID array under the `recipients` key + +#### Scenario: Per-recipient channel fires once per recipient +- **GIVEN** a rule with `channels: ["nc-notification"]` and three resolved recipients +- **WHEN** the rule fires +- **THEN** `emitNotification()` MUST be invoked three times — once per recipient +- **AND** each call MUST receive that recipient's interpolated subject (locale-resolved) + +#### Scenario: Numeric operator AND-logic +- **GIVEN** a `calculatedChange` `condition` map with two operators `{"lt": 1.0, "gt": 0.0}` +- **WHEN** `numericConditionMatches()` is invoked with value `0.5` +- **THEN** the result MUST be true (both operators hold) +- **AND** with value `1.5` the result MUST be false (the `lt` operator fails) +- **AND** with a non-numeric value, every ordering operator (`lt`, `lte`, `gt`, `gte`) MUST return false; `eq` and `ne` MUST cast to string and compare + +#### Scenario: Schema lookup failure is silent +- **GIVEN** an object whose schema reference cannot be resolved (mapper throws) +- **WHEN** the dispatcher attempts to load the schema +- **THEN** the dispatcher MUST return cleanly without throwing +- **AND** no notifications MUST be dispatched for the orphan object + +--- + +### Requirement: REQ-102 — Recipient resolution MUST support six recipient kinds with attacker-controlled-input verification + +`AnnotationNotificationDispatcher::resolveRecipients()` MUST support the kinds `users`, `field`, `groups`, `relation`, `object-acl`, and `expression`. For every UID candidate sourced from object data (`field`, `relation` kinds) or from schema annotation (`users` kind), the resolver MUST verify the candidate against `IUserManager::userExists()` before adding it to the recipient list. Transient lookup failures (`\Throwable` from `userExists`) MUST NOT be cached — only definitive verdicts are cached for the duration of a single request. Per-object ACL recipients (`object-acl` kind) MUST honour the `permission` parameter (`read` includes ACL groups; `manage` returns only the object owner). Expression-kind resolvers MUST be looked up via the injected `IServerContainer` (NOT the static `\OC::$server` accessor) and MUST implement `RecipientResolverInterface::resolve(ObjectEntity, array): array`. + +#### Scenario: `users` kind verifies every declared UID +- **GIVEN** a recipient block `{kind: "users", users: ["alice", "bob", "nonexistent"]}` +- **AND** Nextcloud has `alice` and `bob` as real users but not `nonexistent` +- **WHEN** `resolveRecipients()` is invoked +- **THEN** the returned recipient list MUST contain `alice` and `bob` +- **AND** MUST NOT contain `nonexistent` + +#### Scenario: `field` kind rejects attacker-shaped UIDs from object data +- **GIVEN** an object whose `assignedTo` field is set to the string `"admin"` +- **AND** the user `admin` does NOT exist in Nextcloud +- **AND** a recipient block `{kind: "field", field: "assignedTo"}` +- **WHEN** `resolveRecipients()` is invoked +- **THEN** the resolver MUST NOT include `admin` in the recipient list (fail-closed against attacker-controlled input) +- **AND** if `admin` does exist, the resolver MUST include it (legitimate path) + +#### Scenario: `relation` kind extracts UIDs from nested structures +- **GIVEN** an object whose `assignees` field is `[{userId: "alice"}, {uid: "bob"}, "charlie"]` +- **AND** all three users exist +- **WHEN** `extractUidsFromRelation()` is invoked +- **THEN** the result MUST be `["alice", "bob", "charlie"]` +- **AND** each UID MUST also pass the `userExists` check before being added to the final recipient list + +#### Scenario: `object-acl` kind with `manage` permission returns only the owner +- **GIVEN** an object owned by `alice` with ACL groups `["team-a", "team-b"]` +- **AND** a recipient block `{kind: "object-acl", permission: "manage"}` +- **WHEN** `resolveObjectAclRecipients()` is invoked +- **THEN** the result MUST contain `alice` only +- **AND** MUST NOT expand any ACL groups + +#### Scenario: `object-acl` kind with `read` permission expands ACL groups +- **GIVEN** the same object with `read` permission requested +- **WHEN** `resolveObjectAclRecipients()` is invoked +- **THEN** the result MUST contain the owner `alice` plus every member of `team-a` and `team-b` + +#### Scenario: `expression` kind uses the injected container, never the static accessor +- **GIVEN** a recipient block `{kind: "expression", resolver: "OCA\\App\\MyResolver"}` +- **WHEN** `resolveExpressionRecipients()` is invoked +- **THEN** the resolver MUST be obtained via the constructor-injected `IServerContainer::get()` +- **AND** when the resolved instance does NOT implement `RecipientResolverInterface`, the resolver MUST log a warning and return an empty array +- **AND** when the resolver throws, the dispatcher MUST log a warning and continue with other recipient blocks + +#### Scenario: `userExists` failure is not cached +- **GIVEN** `IUserManager::userExists("flaky-user")` throws once then succeeds on retry +- **WHEN** `userExists("flaky-user")` is called twice within the same request +- **THEN** the first call MUST log a warning and return false WITHOUT writing to the cache +- **AND** the second call MUST retry the lookup (not return a stale cached false) + +#### Scenario: Duplicate recipients are deduplicated +- **GIVEN** multiple recipient blocks that all resolve to include user `alice` +- **WHEN** `resolveRecipients()` finalises +- **THEN** the returned list MUST contain `alice` exactly once +- **AND** insertion order MUST be preserved for the first occurrence + +--- + +### Requirement: REQ-103 — Notification rate limiting MUST use a per-`(rule, recipient)` token bucket + +`RateLimiter::tryConsume()` MUST implement a token-bucket algorithm keyed on the SHA-1 hash of `ruleId + "|" + recipient`. The bucket MUST refill linearly at a configurable rate (default: one token every 60 seconds, capped at the configured bucket size — default 10). Per-rule overrides MUST be honoured under the `rateLimit.bucketSize` and `rateLimit.refillSecondsPerToken` keys on the rule spec. Operators MUST be able to kill the limiter globally via the app-config flag `notification_rate_limit_enabled = false`. The limiter MUST fail open: when the cache backend is unavailable, when the limiter is disabled, when either `ruleId` or `recipient` is empty, or when a state read throws, `tryConsume()` MUST return true (dispatch proceeds). + +#### Scenario: First call to a fresh bucket succeeds +- **GIVEN** a fresh `(rule, recipient)` pair with no existing bucket state +- **WHEN** `tryConsume()` is invoked +- **THEN** the call MUST return true +- **AND** the persisted bucket state MUST have `tokens = bucketSize - 1` + +#### Scenario: Bucket empties after `bucketSize` consecutive dispatches +- **GIVEN** a bucket size of 3 and refill rate of one token per 60 s +- **WHEN** the dispatcher invokes `tryConsume()` four times within the same second +- **THEN** the first three calls MUST return true +- **AND** the fourth call MUST return false +- **AND** an info-level log entry MUST be written containing `"[NotificationRateLimiter] dropped"` plus rule, recipient, and configured limits + +#### Scenario: Tokens refill linearly with time +- **GIVEN** an empty bucket (`tokens = 0`) with refill rate of one token per 60 s +- **WHEN** the time provider advances 90 seconds and `tryConsume()` is invoked +- **THEN** the bucket MUST hold 1.5 tokens − 1 = 0.5 tokens after the consume +- **AND** the call MUST return true +- **AND** the next call inside the same second MUST return false (insufficient tokens) + +#### Scenario: Refill is capped at `bucketSize` +- **GIVEN** a bucket size of 10 and an empty bucket from 24 hours ago +- **WHEN** `tryConsume()` is invoked now +- **THEN** the refill calculation MUST cap the bucket at 10 tokens (NOT 1440 tokens) + +#### Scenario: Per-rule override takes precedence +- **GIVEN** a rule declaring `rateLimit: {bucketSize: 100, refillSecondsPerToken: 1}` +- **WHEN** `tryConsume()` is called against that rule +- **THEN** the resolved limits MUST be `(100, 1)`, not the class defaults `(10, 60)` +- **AND** string-form override values (`"100"`, `"1"`) MUST be accepted as long as they pass `ctype_digit` + +#### Scenario: Kill switch disables all rate limiting +- **GIVEN** `notification_rate_limit_enabled = "false"` in app-config +- **WHEN** `tryConsume()` is invoked with any inputs +- **THEN** the call MUST return true without reading or writing bucket state +- **AND** `"0"` MUST also be accepted as a falsy value + +#### Scenario: Empty `ruleId` or `recipient` fails open +- **GIVEN** either argument is the empty string +- **WHEN** `tryConsume()` is invoked +- **THEN** the call MUST return true (defensive fail-open — empty keys would group unrelated rules) +- **AND** no bucket state MUST be persisted + +#### Scenario: Cache unavailability fails open +- **GIVEN** the constructor caught a `\Throwable` from `ICacheFactory::createDistributed()` and `$this->cache` is null +- **WHEN** `tryConsume()` is invoked +- **THEN** the call MUST return true (dispatch proceeds) +- **AND** the constructor MUST have logged a warning `"[NotificationRateLimiter] cache backend unavailable"` + +#### Scenario: Cache key uses a stable SHA-1 of (rule, recipient) +- **GIVEN** rule `"foo"` and recipient `"alice"` +- **WHEN** `key()` is computed +- **THEN** the result MUST be `"notification:rate:" . sha1("foo|alice")` +- **AND** colons or pipes in either argument MUST NOT change the separator semantics (the hash is the authoritative separator) + +--- + +### Requirement: REQ-104 — Notification grouping MUST use a per-`(rule, recipient)` debounce window with optional max-events flush + +`NotificationCoalescer::shouldDispatch()` MUST implement a debounce window keyed on SHA-1 of `ruleId + "|" + recipient`. Rules opt into coalescing by declaring a `coalesce: {windowSeconds: }` block (optional `maxEvents: `). When a rule has no `coalesce` block, the coalescer MUST be a no-op (return true). When the rule opts in, the first event within a window MUST open the window and proceed; subsequent events within the open window MUST be silenced and only bump the in-window counter. Once `maxEvents` is reached, the next event MUST force a flush dispatch and reset the window. Operators MUST be able to kill the coalescer globally via `notification_coalesce_enabled = false`. The coalescer MUST fail open when the cache backend is missing, the kill switch is set, or a state read throws. + +#### Scenario: Rule without `coalesce` block is a no-op +- **GIVEN** a rule whose spec block does not declare `coalesce` +- **WHEN** `shouldDispatch()` is invoked +- **THEN** the call MUST return true without reading the cache + +#### Scenario: First event opens the window and fires +- **GIVEN** a rule with `coalesce: {windowSeconds: 300}` and no existing window state +- **WHEN** `shouldDispatch()` is invoked +- **THEN** the call MUST return true +- **AND** the persisted state MUST be `{count: 1, opened: }` + +#### Scenario: Subsequent events inside the window are silenced +- **GIVEN** an open window from 60 seconds ago with `count = 3` and `windowSeconds = 300` +- **WHEN** `shouldDispatch()` is invoked again +- **THEN** the call MUST return false +- **AND** the persisted count MUST become 4 +- **AND** the `opened` timestamp MUST NOT change +- **AND** an info-level log entry MUST be written: `"[NotificationCoalescer] silenced rule=... recipient=... count=4 windowSeconds=300"` + +#### Scenario: Window expiry opens a fresh window +- **GIVEN** an open window from 600 seconds ago with `count = 5` and `windowSeconds = 300` +- **WHEN** `shouldDispatch()` is invoked +- **THEN** the call MUST return true (the previous window has expired) +- **AND** the persisted state MUST be reset to `{count: 1, opened: }` + +#### Scenario: `maxEvents` forces a flush before window expires +- **GIVEN** a rule with `coalesce: {windowSeconds: 3600, maxEvents: 5}` +- **AND** the current window state is `{count: 4, opened: <60s ago>}` +- **WHEN** `shouldDispatch()` is invoked +- **THEN** the call MUST return true (forced flush at the 5th event) +- **AND** the persisted state MUST be reset to `{count: 1, opened: }` + +#### Scenario: `inspect()` exposes the current state for diagnostics +- **GIVEN** an existing window state `{count: 12, opened: 1716500000}` +- **WHEN** `inspect()` is called for the same `(rule, recipient)` +- **THEN** the return value MUST be `["count" => 12, "opened" => 1716500000]` +- **AND** when no state exists, `inspect()` MUST return null + +#### Scenario: `windowSeconds <= 0` disables coalescing for that rule +- **GIVEN** a rule with `coalesce: {windowSeconds: 0}` +- **WHEN** `shouldDispatch()` is invoked +- **THEN** the call MUST return true without reading the cache (the parse step returned 0 from `resolveWindowSeconds`) + +#### Scenario: Kill switch fails open +- **GIVEN** `notification_coalesce_enabled = "false"` in app-config +- **WHEN** `shouldDispatch()` is invoked on a rule with a valid `coalesce` block +- **THEN** the call MUST return true (every event fires immediately) + +--- + +### Requirement: REQ-105 — Idempotency-key deduplication MUST claim the dedup slot before sending + +When a rule declares an `idempotencyKey` template, the dispatcher MUST resolve the template against the object, then claim the dedup slot via `NotificationDispatchLogMapper::record()` **before** invoking any channel emit. The claim MUST be the authoritative serialisation point under concurrency, backed by the unique `(notification_slug, idempotency_key)` index installed by migration `Version1Date20260511120000`. On `DuplicateDispatchException` (the index rejected the insert), the dispatcher MUST skip the rule entirely with an info-level log entry. On any other `\Throwable` (table missing in tests, transient infra failure), the dispatcher MUST fail open and proceed with the dispatch, logging at warning level. + +The template syntax MUST support `${@self.}` substitution. The token `@self.id` and `@self.uuid` MUST both resolve to `ObjectEntity::getUuid()`. Other tokens MUST be replaced with the corresponding field from the object's stored data (cast to string), or with the empty string when the field is absent or non-scalar. Each resolved token value MUST be truncated to 128 characters to avoid the 512-character `idempotency_key` column being exhausted by adversarial input. + +A `null` `dispatchLogMapper` (test contexts that did not wire the mapper) MUST be treated as fail-open — no test should be forced to construct the mapper just to pass the guard. + +#### Scenario: Rule without `idempotencyKey` skips the claim +- **GIVEN** a rule whose spec block does not declare `idempotencyKey` +- **WHEN** the dispatcher processes the rule +- **THEN** `claimIdempotencyKey()` MUST NOT be invoked +- **AND** the dispatch MUST proceed normally + +#### Scenario: Claim succeeds for an unseen key — dispatch proceeds +- **GIVEN** a rule with `idempotencyKey: "${@self.id}-T30-${@self.dueDate}"` +- **AND** no existing dedup row for the resolved key +- **WHEN** the dispatcher processes the rule +- **THEN** the dispatch log MUST be inserted BEFORE any emit method is called +- **AND** the dispatch MUST proceed + +#### Scenario: Concurrent dispatcher loses the unique-index race +- **GIVEN** two concurrent dispatchers attempting to claim the same `(slug, key)` slot +- **WHEN** the database unique index rejects the second insert with `DuplicateDispatchException` +- **THEN** the loser MUST log an info entry `"[AnnotationNotificationDispatcher] deduplicated rule=... key=..."` +- **AND** MUST skip the dispatch entirely (no channel emit, no history record under "dispatched") + +#### Scenario: Generic DB failure fails open +- **GIVEN** the dispatch log table is missing (e.g. integration test without the migration applied) +- **WHEN** `claimIdempotencyKey()` is invoked +- **THEN** the resulting `\Throwable` (NOT a `DuplicateDispatchException`) MUST be caught +- **AND** the dispatcher MUST log a warning containing the underlying message +- **AND** MUST return true (dispatch proceeds — infrastructure failure must not silently drop user-visible notifications) + +#### Scenario: `${@self.id}` and `${@self.uuid}` resolve to the object UUID +- **GIVEN** an object with `uuid = "abc-123"` and a template `"${@self.uuid}-foo"` +- **WHEN** `resolveIdempotencyKey()` is invoked +- **THEN** the resolved key MUST equal `"abc-123-foo"` +- **AND** the same result MUST hold when the template uses `${@self.id}` instead + +#### Scenario: Unknown token resolves to empty string +- **GIVEN** an object whose data array does NOT contain the `dueDate` field +- **AND** a template `"${@self.uuid}-${@self.dueDate}"` +- **WHEN** the key is resolved +- **THEN** the resolved key MUST equal `"abc-123-"` (trailing dash preserved; missing field becomes empty) + +#### Scenario: Non-scalar field resolves to empty string +- **GIVEN** an object whose `tags` field is an array (non-scalar) +- **AND** a template `"${@self.uuid}-${@self.tags}"` +- **WHEN** the key is resolved +- **THEN** the substitution for `tags` MUST be empty +- **AND** the resolved key MUST equal `"abc-123-"` + +#### Scenario: Per-token value is truncated to 128 characters +- **GIVEN** an object whose `description` field is a 500-character string +- **AND** a template `"${@self.description}"` +- **WHEN** the key is resolved +- **THEN** the resolved key MUST be exactly 128 characters of the source string +- **AND** the total key MUST never exceed the column limit when the template combines multiple long fields + +#### Scenario: Prune expired rows is best-effort +- **GIVEN** a stale dedup row whose dedup window has passed +- **WHEN** the next claim is attempted +- **THEN** the dispatcher MUST invoke `pruneExpired()` before the claim +- **AND** a `pruneExpired()` failure MUST NOT block the claim (the mapper swallows it internally) + +#### Scenario: Null dispatch-log mapper fails open +- **GIVEN** `$this->dispatchLogMapper === null` (legacy test fixture) +- **WHEN** `claimIdempotencyKey()` is invoked +- **THEN** the call MUST return true without attempting any DB operation diff --git a/openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md b/openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md new file mode 100644 index 0000000000..ff29494f37 --- /dev/null +++ b/openspec/changes/retrofit-2026-05-24-notificatie-engine/tasks.md @@ -0,0 +1,22 @@ +# Tasks + +- [x] task-1: notificatie-engine#REQ-101 — Annotation dispatcher pipeline (dispatch, matches, dispatchBroadcastChannel, recordHistory, recordHistoryAcrossChannels, loadSchema, getAnnotation, numericConditionMatches, constructor) + listener wiring (AnnotationNotificationListener::handle, extractObject, constructor) (retroactive annotation) +- [x] task-2: notificatie-engine#REQ-102 — Recipient resolution across six kinds (resolveRecipients, resolveObjectAclRecipients, resolveExpressionRecipients, extractUidsFromRelation, userExists; RecipientResolverInterface::resolve contract) (retroactive annotation) +- [x] task-3: notificatie-engine#REQ-103 — Token-bucket rate limiter (RateLimiter::tryConsume, isEnabled, resolveLimits, key, persist, constructor; AnnotationNotificationDispatcher::rateLimitAllows wrapper) (retroactive annotation) +- [x] task-4: notificatie-engine#REQ-104 — Per-(rule, recipient) coalescer (NotificationCoalescer::shouldDispatch, inspect, isEnabled, resolveWindowSeconds, resolveMaxEvents, key, persist, constructor; AnnotationNotificationDispatcher::coalesceAllows wrapper) (retroactive annotation) +- [x] task-5: notificatie-engine#REQ-105 — Idempotency-key claim-first dedup (AnnotationNotificationDispatcher::claimIdempotencyKey, resolveIdempotencyKey) (retroactive annotation) + +## future-pass:next + +The following 57 methods from `/tmp/or-scan/rspec-cluster-notificatie-engine.json` are deferred to a follow-up pass. They sort cleanly into 8 additional REQs (suggested IDs only — confirm at draft time): + +- **future-task-6: notificatie-engine#REQ-106** — Channel emit surfaces: `AnnotationNotificationDispatcher::emitNotification`, `emitEmail`, `emitActivity`, `emitWebhook`, `emitTalk` +- **future-task-7: notificatie-engine#REQ-107** — Per-locale subject resolution + interpolation: `resolveLocalizedSubject`, `resolveUserLocale`, `interpolate` +- **future-task-8: notificatie-engine#REQ-108** — Organisation pinning gate: `organisationGateAllows`; `NotificationAnnotationValidator::validateOrganisationGate` +- **future-task-9: notificatie-engine#REQ-109** — Subscription gating: `filterBySubscription`; `NotificationSubscriptionsController::index`, `create`, `destroy`, `resolveUserId`, `coerceNullableInt`; `notificationSubscriptions.js::listSubscriptions`, `subscribe`, `unsubscribe`, `hasSubscription`; `NotificationSubscriptionToggle.vue::refresh`; `NotificationsSection.vue::save` +- **future-task-10: notificatie-engine#REQ-110** — Schema-save annotation validation: `NotificationAnnotationValidator::validate` +- **future-task-11: notificatie-engine#REQ-111** — Persistent-webhook materialisation: `NotificationsAnnotationInstaller::__construct`, `installSchema`, `handle`, `upsertWebhook`, `findByName` +- **future-task-12: notificatie-engine#REQ-112** — Scheduled / threshold / batch jobs + digest queue + read-state: `ScheduledNotificationJob::isDue`, `markFired`, `matchesFilter`, `__construct`, `run`, `processSchema`, `stateKey`, `fire`; `BatchNotificationJob::run`; `NotificationDigest::recipientCount`, `totalPending`, `pendingCount`, `flush`; `NotificationReadState::markUnread`, `readCount`, `key`, `isRead` +- **future-task-13: notificatie-engine#REQ-113** — Notification history query API: `NotificationHistoryController::extractFilters`, `resolveLimit`, `resolveOffset` +- **future-task-14: notificatie-engine#REQ-114** — VNG envelope shape: `VngNotificatiesEnvelope::mapAction` +- **future-task-15: notificatie-engine#REQ-115** — Renderer-side (INotifier) refinement: `AnnotationNotifier::__construct`, `getName`, `getID`, `prepare`; legacy `Notifier::__construct`, `getName` (re-point existing `@spec` to new REQ)