Skip to main content

notification/
plugin.rs

1use std::{cell::RefCell, fmt, rc::Rc, time::Duration as StdDuration};
2
3use chrono::{DateTime, Datelike, SecondsFormat, Utc};
4use lenso::prelude::*;
5use lenso_capability_email_dispatch as email;
6use lenso_capability_email_dispatch::{
7    DispatchRequest, DispatchResponseOutcome, EmailDispatchInvocationError,
8};
9use lenso_capability_notification_admin as admin;
10use lenso_capability_notification_delivery as delivery;
11use lenso_capability_notification_template as notification_template;
12use lenso_capability_notification_transactional as transactional;
13use lenso_capability_secrets::{ResolveRequest, SecretsInvocationError};
14use lenso_postgres_kit::OwnedPostgres;
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17use zeroize::Zeroizing;
18
19use crate::contracts::{
20    DispatchOutcome, EMAIL_DISPATCH_OBSERVED_EVENT, EMAIL_RECEIPT_OBSERVED_EVENT,
21    EmailDispatchObserved, EmailReceiptObserved, ORGANIZATION_INVITATION_ACCEPTED_EVENT,
22    ORGANIZATION_INVITATION_EXPIRED_EVENT, ORGANIZATION_INVITATION_REVOKED_EVENT,
23    OrganizationInvitationLifecycle, ReceiptKind, RemoteReceiptSummary, SanitizedFailure,
24};
25use crate::domain::MAX_SAFE_WIRE_INTEGER;
26use crate::error::{ErrorCode, NotificationError};
27use crate::events::{NotificationEventApplier, ObservationEnvelope};
28use crate::migrations::schema_plan;
29use crate::operator::verify_managed_catalog;
30use crate::public::{
31    AccessRequestNotificationEvent, AccessRequestNotificationTemplateV1, AccessRequestRoleV1,
32    AccessRequestScopeV1, CreateAccessRequestNotificationIntent, CreateTransactionalEmailIntent,
33    EmailRecipient, IntentSource, OrganizationInvitationTemplateV1, RenderedTemplate,
34    access_request_template_id, create_access_request_notification_in_tx,
35    create_transactional_email_intent_in_tx, find_access_request_notification_replay,
36    find_transactional_email_intent_replay,
37};
38use crate::repository::{
39    ADMIN_ATTEMPT_LIMIT, ADMIN_RECEIPT_LIMIT, ADMIN_RETRY_REQUEST_LIMIT, AttemptRecord,
40    DeliveryDetail, DeliverySummary, PostgresNotificationRepository, ReceiptRecord, RetryRecord,
41    RetryResult,
42};
43use crate::runtime::{DispatchWork, claim_one_due};
44use crate::snapshot::AeadSnapshotProtector;
45
46const DEPENDENCY_TIMEOUT: StdDuration = StdDuration::from_secs(10);
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct NotificationConfig {
50    schema: String,
51    database_url_secret: String,
52    snapshot_key_secret: String,
53    transactional_callers: Vec<String>,
54    dispatch_callers: Vec<String>,
55    receipt_callers: Vec<String>,
56    admin_callers: Vec<String>,
57}
58
59impl NotificationConfig {
60    pub fn new(
61        database_url_secret: impl Into<String>,
62        snapshot_key_secret: impl Into<String>,
63        transactional_callers: Vec<String>,
64        dispatch_callers: Vec<String>,
65        receipt_callers: Vec<String>,
66        admin_callers: Vec<String>,
67    ) -> Result<Self, NotificationConfigError> {
68        let config = Self {
69            schema: "notification".to_owned(),
70            database_url_secret: database_url_secret.into(),
71            snapshot_key_secret: snapshot_key_secret.into(),
72            transactional_callers,
73            dispatch_callers,
74            receipt_callers,
75            admin_callers,
76        };
77        config.validate()?;
78        Ok(config)
79    }
80
81    fn validate(&self) -> Result<(), NotificationConfigError> {
82        if self.schema != "notification" {
83            return Err(NotificationConfigError::InvalidSchema);
84        }
85        if !valid_secret_reference(&self.database_url_secret)
86            || !valid_secret_reference(&self.snapshot_key_secret)
87            || self.database_url_secret == self.snapshot_key_secret
88        {
89            return Err(NotificationConfigError::InvalidSecretReference);
90        }
91        for callers in [
92            &self.transactional_callers,
93            &self.dispatch_callers,
94            &self.receipt_callers,
95            &self.admin_callers,
96        ] {
97            if callers.is_empty()
98                || callers.len() > 64
99                || callers.iter().any(|caller| !valid_instance(caller))
100                || callers
101                    .iter()
102                    .enumerate()
103                    .any(|(index, caller)| callers[..index].contains(caller))
104            {
105                return Err(NotificationConfigError::InvalidCallers);
106            }
107        }
108        Ok(())
109    }
110}
111
112#[derive(Clone, Debug, Error, Eq, PartialEq)]
113pub enum NotificationConfigError {
114    #[error("the legacy Notification schema identity must remain `notification`")]
115    InvalidSchema,
116    #[error("database and snapshot keys require distinct valid secret references")]
117    InvalidSecretReference,
118    #[error("each authority role requires unique valid caller Instance keys")]
119    InvalidCallers,
120}
121
122fn validate_config(config: &NotificationConfig) -> Result<(), RuntimeFailure> {
123    config
124        .validate()
125        .map_err(|error| RuntimeFailure::InvalidResolvedPlan {
126            detail: error.to_string(),
127        })
128}
129
130#[lenso::plugin(
131    lifecycle,
132    configuration_schema = "configuration.schema.json",
133    validate = validate_config
134)]
135#[derive(Clone)]
136struct NotificationPlugin {
137    #[config]
138    config: NotificationConfig,
139    secrets: Port<lenso_capability_secrets::SecretsClient>,
140    email: Port<email::EmailDispatchClient>,
141    templates: Port<notification_template::NotificationTemplateClient>,
142    state: Rc<RefCell<Option<PreparedNotification>>>,
143}
144
145#[derive(Clone)]
146struct PreparedNotification {
147    postgres: OwnedPostgres,
148    protector: AeadSnapshotProtector,
149    email_provider_instance: String,
150}
151
152impl fmt::Debug for PreparedNotification {
153    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
154        formatter
155            .debug_struct("PreparedNotification")
156            .field("schema", &self.postgres.schema())
157            .field("email_provider_instance", &self.email_provider_instance)
158            .finish_non_exhaustive()
159    }
160}
161
162impl fmt::Debug for NotificationPlugin {
163    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
164        formatter
165            .debug_struct("NotificationPlugin")
166            .field("prepared", &self.state.borrow().is_some())
167            .field(
168                "transactional_caller_count",
169                &self.config.transactional_callers.len(),
170            )
171            .field("dispatch_caller_count", &self.config.dispatch_callers.len())
172            .field("receipt_caller_count", &self.config.receipt_callers.len())
173            .field("admin_caller_count", &self.config.admin_callers.len())
174            .finish_non_exhaustive()
175    }
176}
177
178#[lenso::provides(transactional::Transactional, delivery::Delivery, admin::Admin)]
179impl NotificationPlugin {
180    async fn create_organization_invitation(
181        &self,
182        context: Ctx,
183        request: transactional::CreateOrganizationInvitationRequest,
184    ) -> PluginResult<
185        transactional::CreateOrganizationInvitationResponse,
186        transactional::CreateOrganizationInvitationError,
187    > {
188        let Some(caller) =
189            authorized_caller(&context, &self.config.transactional_callers).map(str::to_owned)
190        else {
191            return Err(PluginError::domain(
192                transactional::CreateOrganizationInvitationError::Unauthorized,
193            ));
194        };
195        let now = Utc::now();
196        if !valid_create_request(&request, now) {
197            return Err(PluginError::domain(
198                transactional::CreateOrganizationInvitationError::InvalidIntent,
199            ));
200        }
201        let expires_at = parse_time(&request.template.expires_at).map_err(|_| {
202            PluginError::domain(transactional::CreateOrganizationInvitationError::InvalidIntent)
203        })?;
204        let locale = match request.recipient.locale {
205            transactional::CreateOrganizationInvitationRequestRecipientLocale::En => "en",
206            transactional::CreateOrganizationInvitationRequestRecipientLocale::EnUS => "en-US",
207        };
208        let intent = CreateTransactionalEmailIntent {
209            source: intent_source(caller, request.source),
210            recipient: EmailRecipient {
211                address: request.recipient.address,
212                display_name: request.recipient.display_name,
213                locale: locale.to_owned(),
214            },
215            template: OrganizationInvitationTemplateV1 {
216                organization_id: request.template.organization_id,
217                organization_name: request.template.organization_name,
218                invitation_id: request.template.invitation_id,
219                invitation_url: request.template.invitation_url,
220                inviter_display_name: request.template.inviter_display_name,
221                role_name: request.template.role_name,
222                expires_at,
223            },
224            idempotency_key: request.idempotency_key,
225            correlation_id: request.correlation_id,
226            causation_id: request.causation_id,
227            requested_by: request.requested_by,
228        };
229        let prepared = self.prepared().map_err(PluginError::runtime)?;
230        if let Some(receipt) =
231            find_transactional_email_intent_replay(prepared.postgres.pool(), &intent, now)
232                .await
233                .map_err(map_create_error)?
234        {
235            return Ok(transactional::CreateOrganizationInvitationResponse {
236                delivery_id: receipt.delivery_id,
237                idempotent_replay: true,
238                intent_id: receipt.intent_id,
239                status: transactional::CreateOrganizationInvitationResponseStatus::Queued,
240            });
241        }
242        let rendered = self
243            .render_organization_invitation(context, &intent)
244            .await
245            .map_err(PluginError::runtime)?;
246        let mut transaction = prepared
247            .postgres
248            .pool()
249            .begin()
250            .await
251            .map_err(|error| PluginError::runtime(runtime(error)))?;
252        let receipt = create_transactional_email_intent_in_tx(
253            &mut transaction,
254            &intent,
255            &rendered,
256            now,
257            &prepared.protector,
258        )
259        .await
260        .map_err(map_create_error)?;
261        transaction
262            .commit()
263            .await
264            .map_err(|error| PluginError::runtime(runtime(error)))?;
265        Ok(transactional::CreateOrganizationInvitationResponse {
266            delivery_id: receipt.delivery_id,
267            idempotent_replay: receipt.idempotent_replay,
268            intent_id: receipt.intent_id,
269            status: transactional::CreateOrganizationInvitationResponseStatus::Queued,
270        })
271    }
272
273    async fn create_access_request_notification(
274        &self,
275        context: Ctx,
276        request: transactional::CreateAccessRequestNotificationRequest,
277    ) -> PluginResult<
278        transactional::CreateAccessRequestNotificationResponse,
279        transactional::CreateAccessRequestNotificationError,
280    > {
281        let Some(caller) =
282            authorized_caller(&context, &self.config.transactional_callers).map(str::to_owned)
283        else {
284            return Err(PluginError::domain(
285                transactional::CreateAccessRequestNotificationError::Unauthorized,
286            ));
287        };
288        let now = Utc::now();
289        if !valid_access_request_notification_request(&request, now) {
290            return Err(PluginError::domain(
291                transactional::CreateAccessRequestNotificationError::InvalidIntent,
292            ));
293        }
294        let event = match request.event {
295            transactional::CreateAccessRequestNotificationRequestEvent::Submitted => {
296                AccessRequestNotificationEvent::Submitted
297            }
298            transactional::CreateAccessRequestNotificationRequestEvent::Approved => {
299                AccessRequestNotificationEvent::Approved
300            }
301            transactional::CreateAccessRequestNotificationRequestEvent::Denied => {
302                AccessRequestNotificationEvent::Denied
303            }
304            transactional::CreateAccessRequestNotificationRequestEvent::Expiring => {
305                AccessRequestNotificationEvent::Expiring
306            }
307        };
308        let expires_at = request
309            .expires_at
310            .as_deref()
311            .map(parse_time)
312            .transpose()
313            .map_err(|_| {
314                PluginError::domain(
315                    transactional::CreateAccessRequestNotificationError::InvalidIntent,
316                )
317            })?;
318        let locale = match request.recipient.locale {
319            transactional::CreateAccessRequestNotificationRequestRecipientLocale::En => "en",
320            transactional::CreateAccessRequestNotificationRequestRecipientLocale::EnUS => "en-US",
321        };
322        let intent = CreateAccessRequestNotificationIntent {
323            source: IntentSource {
324                module_id: caller,
325                entity_type: "access_request".to_owned(),
326                entity_id: request.request_id.clone(),
327            },
328            recipient: EmailRecipient {
329                address: request.recipient.address,
330                display_name: request.recipient.display_name,
331                locale: locale.to_owned(),
332            },
333            template: AccessRequestNotificationTemplateV1 {
334                request_id: request.request_id,
335                organization_id: request.organization_id,
336                event,
337                role: AccessRequestRoleV1 {
338                    role_id: request.role.role_id,
339                    display_name: request.role.display_name,
340                },
341                scope: AccessRequestScopeV1 {
342                    kind: request.scope.kind,
343                    id: request.scope.id,
344                    display_name: request.scope.display_name,
345                },
346                expires_at,
347            },
348            idempotency_key: request.idempotency_key,
349            correlation_id: request.correlation_id,
350            causation_id: request.causation_id,
351            requested_by: request.requested_by,
352        };
353        let prepared = self.prepared().map_err(PluginError::runtime)?;
354        if let Some(receipt) =
355            find_access_request_notification_replay(prepared.postgres.pool(), &intent, now)
356                .await
357                .map_err(map_access_request_create_error)?
358        {
359            return Ok(transactional::CreateAccessRequestNotificationResponse {
360                delivery_id: receipt.delivery_id,
361                idempotent_replay: true,
362                intent_id: receipt.intent_id,
363                status: transactional::CreateAccessRequestNotificationResponseStatus::Queued,
364            });
365        }
366        let rendered = self
367            .render_access_request_notification(context, &intent)
368            .await
369            .map_err(PluginError::runtime)?;
370        let mut transaction = prepared
371            .postgres
372            .pool()
373            .begin()
374            .await
375            .map_err(|error| PluginError::runtime(runtime(error)))?;
376        let receipt = create_access_request_notification_in_tx(
377            &mut transaction,
378            &intent,
379            &rendered,
380            now,
381            &prepared.protector,
382        )
383        .await
384        .map_err(map_access_request_create_error)?;
385        transaction
386            .commit()
387            .await
388            .map_err(|error| PluginError::runtime(runtime(error)))?;
389        Ok(transactional::CreateAccessRequestNotificationResponse {
390            delivery_id: receipt.delivery_id,
391            idempotent_replay: receipt.idempotent_replay,
392            intent_id: receipt.intent_id,
393            status: transactional::CreateAccessRequestNotificationResponseStatus::Queued,
394        })
395    }
396
397    async fn observe_invitation_lifecycle(
398        &self,
399        context: Ctx,
400        request: transactional::ObserveInvitationLifecycleRequest,
401    ) -> PluginResult<
402        transactional::ObserveInvitationLifecycleResponse,
403        transactional::ObserveInvitationLifecycleError,
404    > {
405        let Some(caller) =
406            authorized_caller(&context, &self.config.transactional_callers).map(str::to_owned)
407        else {
408            return Err(PluginError::domain(
409                transactional::ObserveInvitationLifecycleError::Unauthorized,
410            ));
411        };
412        if !valid_lifecycle_request(&request) {
413            return Err(PluginError::domain(
414                transactional::ObserveInvitationLifecycleError::InvalidObservation,
415            ));
416        }
417        let observed_at = parse_time(&request.observed_at).map_err(|_| {
418            PluginError::domain(transactional::ObserveInvitationLifecycleError::InvalidObservation)
419        })?;
420        let prepared = self.prepared().map_err(PluginError::runtime)?;
421        let event_name = match request.lifecycle {
422            transactional::ObserveInvitationLifecycleRequestLifecycle::Accepted => {
423                ORGANIZATION_INVITATION_ACCEPTED_EVENT
424            }
425            transactional::ObserveInvitationLifecycleRequestLifecycle::Expired => {
426                ORGANIZATION_INVITATION_EXPIRED_EVENT
427            }
428            transactional::ObserveInvitationLifecycleRequestLifecycle::Revoked => {
429                ORGANIZATION_INVITATION_REVOKED_EVENT
430            }
431        };
432        let payload = OrganizationInvitationLifecycle {
433            invitation_id: request.invitation_id.clone(),
434            organization_id: request.organization_id,
435            observed_at,
436        };
437        apply_observation(
438            prepared.postgres.pool(),
439            ObservationEnvelope {
440                id: request.observation_id,
441                event_name: event_name.to_owned(),
442                event_version: 1,
443                source_module: caller,
444                aggregate_id: request.invitation_id,
445                occurred_at: observed_at,
446                payload: serde_json::to_value(payload)
447                    .map_err(|error| PluginError::runtime(runtime(error)))?,
448            },
449        )
450        .await
451        .map_err(map_lifecycle_error)?;
452        Ok(transactional::ObserveInvitationLifecycleResponse { recorded: true })
453    }
454
455    async fn dispatch_due(
456        &self,
457        context: Ctx,
458        _request: delivery::DispatchDueRequest,
459    ) -> PluginResult<delivery::DispatchDueResponse, delivery::DispatchDueError> {
460        if !authorized(&context, &self.config.dispatch_callers) {
461            return Err(PluginError::domain(
462                delivery::DispatchDueError::Unauthorized,
463            ));
464        }
465        let prepared = self.prepared().map_err(PluginError::runtime)?;
466        let now = Utc::now();
467        let Some(work) = claim_one_due(prepared.postgres.pool(), &prepared.protector, now)
468            .await
469            .map_err(|error| PluginError::runtime(runtime(error)))?
470        else {
471            return Err(PluginError::domain(
472                delivery::DispatchDueError::NoDeliveryDue,
473            ));
474        };
475        let dispatch = dispatch_request(&work);
476        let response = match self.email.dispatch(dispatch).await {
477            Ok(response) => response,
478            Err(EmailDispatchInvocationError::Domain(email::DispatchError::InvalidDispatch)) => {
479                let observed = permanent_rejection(
480                    &work,
481                    &prepared.email_provider_instance,
482                    "email_invalid_dispatch",
483                    now,
484                );
485                apply_dispatch(prepared.postgres.pool(), &work, &observed)
486                    .await
487                    .map_err(|error| PluginError::runtime(runtime(error)))?;
488                return Err(PluginError::domain(
489                    delivery::DispatchDueError::DispatchRejected,
490                ));
491            }
492            Err(EmailDispatchInvocationError::Domain(email::DispatchError::UnsupportedMessage)) => {
493                let observed = permanent_rejection(
494                    &work,
495                    &prepared.email_provider_instance,
496                    "email_unsupported_message",
497                    now,
498                );
499                apply_dispatch(prepared.postgres.pool(), &work, &observed)
500                    .await
501                    .map_err(|error| PluginError::runtime(runtime(error)))?;
502                return Err(PluginError::domain(
503                    delivery::DispatchDueError::DispatchRejected,
504                ));
505            }
506            Err(EmailDispatchInvocationError::Domain(email::DispatchError::Unknown(_))) => {
507                let observed = unknown_dispatch(
508                    &work,
509                    &prepared.email_provider_instance,
510                    now,
511                    "email_dispatch_unknown_domain_error",
512                );
513                apply_dispatch(prepared.postgres.pool(), &work, &observed)
514                    .await
515                    .map_err(|error| PluginError::runtime(runtime(error)))?;
516                return Err(PluginError::runtime(email_protocol_violation()));
517            }
518            Err(EmailDispatchInvocationError::Runtime(error)) => {
519                let observed = unknown_dispatch(
520                    &work,
521                    &prepared.email_provider_instance,
522                    now,
523                    "email_dispatch_runtime_failure",
524                );
525                apply_dispatch(prepared.postgres.pool(), &work, &observed)
526                    .await
527                    .map_err(|storage| PluginError::runtime(runtime(storage)))?;
528                return Err(PluginError::runtime(error));
529            }
530        };
531        let observed =
532            match dispatch_observation(&work, &prepared.email_provider_instance, response) {
533                Ok(observed) => observed,
534                Err(error) => {
535                    let observed = unknown_dispatch(
536                        &work,
537                        &prepared.email_provider_instance,
538                        now,
539                        "email_dispatch_protocol_failure",
540                    );
541                    apply_dispatch(prepared.postgres.pool(), &work, &observed)
542                        .await
543                        .map_err(|storage| PluginError::runtime(runtime(storage)))?;
544                    return Err(PluginError::runtime(error));
545                }
546            };
547        apply_dispatch(prepared.postgres.pool(), &work, &observed)
548            .await
549            .map_err(|error| PluginError::runtime(runtime(error)))?;
550        let detail = PostgresNotificationRepository::from_pool(prepared.postgres.pool().clone())
551            .get_delivery(&work.claim.delivery_id)
552            .await
553            .map_err(|error| PluginError::runtime(runtime(error)))?
554            .ok_or_else(|| PluginError::runtime(runtime("claimed delivery disappeared")))?;
555        let next_attempt_at = detail
556            .delivery
557            .next_attempt_at
558            .map(format_time)
559            .transpose()
560            .map_err(PluginError::runtime)?;
561        let observed_at = format_time(observed.observed_at).map_err(PluginError::runtime)?;
562        Ok(delivery::DispatchDueResponse {
563            attempt_id: work.claim.attempt_id,
564            delivery_id: work.claim.delivery_id,
565            next_attempt_at,
566            observed_at,
567            run_id: work.claim.run_id,
568            status: delivery_status(&detail.delivery.status).map_err(PluginError::runtime)?,
569        })
570    }
571
572    async fn observe_receipt(
573        &self,
574        context: Ctx,
575        request: delivery::ObserveReceiptRequest,
576    ) -> PluginResult<delivery::ObserveReceiptResponse, delivery::ObserveReceiptError> {
577        let Some(caller) =
578            authorized_caller(&context, &self.config.receipt_callers).map(str::to_owned)
579        else {
580            return Err(PluginError::domain(
581                delivery::ObserveReceiptError::Unauthorized,
582            ));
583        };
584        if !valid_receipt_request(&request) {
585            return Err(PluginError::domain(
586                delivery::ObserveReceiptError::InvalidReceipt,
587            ));
588        }
589        let observed_at = parse_time(&request.observed_at)
590            .map_err(|_| PluginError::domain(delivery::ObserveReceiptError::InvalidReceipt))?;
591        let prepared = self.prepared().map_err(PluginError::runtime)?;
592        let kind = match request.kind {
593            delivery::ObserveReceiptRequestKind::Delivered => ReceiptKind::Delivered,
594            delivery::ObserveReceiptRequestKind::Bounced => ReceiptKind::Bounced,
595            delivery::ObserveReceiptRequestKind::Rejected => ReceiptKind::Rejected,
596        };
597        let aggregate_id = request.delivery_id.clone();
598        let payload = receipt_observation(caller.clone(), &request, kind, observed_at);
599        apply_observation(
600            prepared.postgres.pool(),
601            ObservationEnvelope {
602                id: request.observation_id,
603                event_name: EMAIL_RECEIPT_OBSERVED_EVENT.to_owned(),
604                event_version: 1,
605                source_module: caller,
606                aggregate_id,
607                occurred_at: observed_at,
608                payload: serde_json::to_value(payload)
609                    .map_err(|error| PluginError::runtime(runtime(error)))?,
610            },
611        )
612        .await
613        .map_err(map_receipt_error)?;
614        Ok(delivery::ObserveReceiptResponse { recorded: true })
615    }
616
617    async fn list_deliveries(
618        &self,
619        context: Ctx,
620        request: admin::ListDeliveriesRequest,
621    ) -> PluginResult<admin::ListDeliveriesResponse, admin::ListDeliveriesError> {
622        if !authorized(&context, &self.config.admin_callers) {
623            return Err(PluginError::domain(
624                admin::ListDeliveriesError::Unauthorized,
625            ));
626        }
627        if !valid_list_request(&request) {
628            return Err(PluginError::domain(
629                admin::ListDeliveriesError::InvalidFilter,
630            ));
631        }
632        let limit = request.limit.unwrap_or(100);
633        let status = request.status.as_ref().map(admin_status);
634        let prepared = self.prepared().map_err(PluginError::runtime)?;
635        let rows = PostgresNotificationRepository::from_pool(prepared.postgres.pool().clone())
636            .list_deliveries(limit + 1, request.cursor.as_deref(), status)
637            .await
638            .map_err(|error| PluginError::runtime(runtime(error)))?;
639        let has_more = i64::try_from(rows.len()).is_ok_and(|count| count > limit);
640        let records = rows
641            .into_iter()
642            .take(usize::try_from(limit).unwrap_or_default())
643            .map(admin_delivery)
644            .collect::<Result<Vec<_>, _>>()
645            .map_err(PluginError::runtime)?;
646        let next_cursor = has_more
647            .then(|| records.last().map(|record| record.id.clone()))
648            .flatten();
649        Ok(admin::ListDeliveriesResponse {
650            next_cursor,
651            records,
652        })
653    }
654
655    async fn get_delivery(
656        &self,
657        context: Ctx,
658        request: admin::GetDeliveryRequest,
659    ) -> PluginResult<admin::GetDeliveryResponse, admin::GetDeliveryError> {
660        if !authorized(&context, &self.config.admin_callers) {
661            return Err(PluginError::domain(admin::GetDeliveryError::Unauthorized));
662        }
663        if !required_bounded(&request.delivery_id, 1, 160) {
664            return Err(PluginError::domain(admin::GetDeliveryError::InvalidRequest));
665        }
666        let prepared = self.prepared().map_err(PluginError::runtime)?;
667        let detail = PostgresNotificationRepository::from_pool(prepared.postgres.pool().clone())
668            .get_delivery(&request.delivery_id)
669            .await
670            .map_err(map_get_error)?
671            .ok_or_else(|| PluginError::domain(admin::GetDeliveryError::DeliveryNotFound))?;
672        admin_detail(detail).map_err(PluginError::runtime)
673    }
674
675    async fn retry_delivery(
676        &self,
677        context: Ctx,
678        request: admin::RetryDeliveryRequest,
679    ) -> PluginResult<admin::RetryDeliveryResponse, admin::RetryDeliveryError> {
680        let Some(caller) =
681            authorized_caller(&context, &self.config.admin_callers).map(str::to_owned)
682        else {
683            return Err(PluginError::domain(admin::RetryDeliveryError::Unauthorized));
684        };
685        if !valid_retry_request(&request) {
686            return Err(PluginError::domain(
687                admin::RetryDeliveryError::InvalidRequest,
688            ));
689        }
690        if request.revision == MAX_SAFE_WIRE_INTEGER {
691            return Err(PluginError::domain(
692                admin::RetryDeliveryError::RetryNotAllowed,
693            ));
694        }
695        let prepared = self.prepared().map_err(PluginError::runtime)?;
696        let result = PostgresNotificationRepository::from_pool(prepared.postgres.pool().clone())
697            .request_manual_retry(
698                &request.delivery_id,
699                request.revision,
700                &request.idempotency_key,
701                &caller,
702                Utc::now(),
703            )
704            .await
705            .map_err(map_retry_error)?;
706        admin_retry(result).map_err(PluginError::runtime)
707    }
708}
709
710impl NotificationPlugin {
711    fn prepared(&self) -> Result<PreparedNotification, RuntimeFailure> {
712        self.state
713            .borrow()
714            .clone()
715            .ok_or_else(|| RuntimeFailure::PluginFailure {
716                detail: "Notification Plugin is not prepared".to_owned(),
717            })
718    }
719
720    async fn render_organization_invitation(
721        &self,
722        context: Ctx,
723        intent: &CreateTransactionalEmailIntent,
724    ) -> Result<RenderedTemplate, RuntimeFailure> {
725        self.templates
726            .render_with_context(context, organization_invitation_render_request(intent))
727            .await
728            .map(rendered_template)
729            .map_err(map_template_render_error)
730    }
731
732    async fn render_access_request_notification(
733        &self,
734        context: Ctx,
735        intent: &CreateAccessRequestNotificationIntent,
736    ) -> Result<RenderedTemplate, RuntimeFailure> {
737        self.templates
738            .render_with_context(context, access_request_render_request(intent))
739            .await
740            .map(rendered_template)
741            .map_err(map_template_render_error)
742    }
743}
744
745fn organization_invitation_render_request(
746    intent: &CreateTransactionalEmailIntent,
747) -> notification_template::RenderRequest {
748    notification_template::RenderRequest {
749        template_id: crate::public::ORGANIZATION_INVITATION_TEMPLATE_ID.to_owned(),
750        version: Some(crate::public::ORGANIZATION_INVITATION_TEMPLATE_VERSION.to_owned()),
751        locale: intent.recipient.locale.clone(),
752        variables: render_variables([
753            (
754                "expires_at",
755                format_template_time(intent.template.expires_at),
756            ),
757            ("invitation_url", intent.template.invitation_url.clone()),
758            (
759                "inviter_display_name",
760                trimmed_optional(intent.template.inviter_display_name.as_deref()),
761            ),
762            ("locale", intent.recipient.locale.clone()),
763            (
764                "organization_name",
765                intent.template.organization_name.trim().to_owned(),
766            ),
767            (
768                "recipient_display_name",
769                trimmed_optional(intent.recipient.display_name.as_deref()),
770            ),
771            (
772                "role_name",
773                trimmed_optional(intent.template.role_name.as_deref()),
774            ),
775        ]),
776    }
777}
778
779fn access_request_render_request(
780    intent: &CreateAccessRequestNotificationIntent,
781) -> notification_template::RenderRequest {
782    let role = intent
783        .template
784        .role
785        .display_name
786        .as_deref()
787        .map(str::trim)
788        .filter(|value| !value.is_empty())
789        .unwrap_or(&intent.template.role.role_id)
790        .to_owned();
791    let scope = intent
792        .template
793        .scope
794        .display_name
795        .as_deref()
796        .map(str::trim)
797        .filter(|value| !value.is_empty())
798        .unwrap_or(&intent.template.scope.id)
799        .to_owned();
800    let mut variables = vec![
801        render_variable("locale", intent.recipient.locale.clone()),
802        render_variable("organization_id", intent.template.organization_id.clone()),
803        render_variable(
804            "recipient_display_name",
805            trimmed_optional(intent.recipient.display_name.as_deref()),
806        ),
807        render_variable("request_id", intent.template.request_id.clone()),
808        render_variable("role", role),
809        render_variable("scope", scope),
810        render_variable("scope_kind", intent.template.scope.kind.clone()),
811    ];
812    if intent.template.event != AccessRequestNotificationEvent::Denied {
813        variables.push(render_variable(
814            "expires_at",
815            intent
816                .template
817                .expires_at
818                .map(format_template_time)
819                .unwrap_or_default(),
820        ));
821    }
822    variables.sort_by(|left, right| left.name.cmp(&right.name));
823    notification_template::RenderRequest {
824        template_id: access_request_template_id(intent.template.event).to_owned(),
825        version: Some(crate::public::ACCESS_REQUEST_TEMPLATE_VERSION.to_owned()),
826        locale: intent.recipient.locale.clone(),
827        variables,
828    }
829}
830
831fn render_variables<const N: usize>(
832    variables: [(&str, String); N],
833) -> Vec<notification_template::RenderRequestVariablesItem> {
834    variables
835        .into_iter()
836        .map(|(name, value)| render_variable(name, value))
837        .collect()
838}
839
840fn render_variable(name: &str, value: String) -> notification_template::RenderRequestVariablesItem {
841    notification_template::RenderRequestVariablesItem {
842        name: name.to_owned(),
843        value,
844    }
845}
846
847fn trimmed_optional(value: Option<&str>) -> String {
848    value.map(str::trim).unwrap_or_default().to_owned()
849}
850
851fn format_template_time(value: DateTime<Utc>) -> String {
852    value.to_rfc3339_opts(SecondsFormat::Secs, true)
853}
854
855fn rendered_template(response: notification_template::RenderResponse) -> RenderedTemplate {
856    RenderedTemplate {
857        template_id: response.template_id,
858        template_version: response.version,
859        requested_locale: response.requested_locale,
860        resolved_locale: response.resolved_locale,
861        fallback_used: response.fallback_used,
862        renderer_identity: response.renderer_identity,
863        template_digest: response.template_digest,
864        content_digest: response.content_digest,
865        subject: response.subject,
866        text: response.text,
867        html: response.html,
868    }
869}
870
871fn map_template_render_error(
872    error: notification_template::NotificationTemplateRenderInvocationError,
873) -> RuntimeFailure {
874    match error {
875        notification_template::NotificationTemplateRenderInvocationError::Runtime(error) => error,
876        notification_template::NotificationTemplateRenderInvocationError::Domain(
877            notification_template::RenderError::NotFound,
878        ) => RuntimeFailure::PluginFailure {
879            detail: "required Notification Template version is unavailable".to_owned(),
880        },
881        notification_template::NotificationTemplateRenderInvocationError::Domain(
882            notification_template::RenderError::Unauthorized,
883        ) => RuntimeFailure::PluginFailure {
884            detail:
885                "Notification is not authorized to render through its configured Template Provider"
886                    .to_owned(),
887        },
888        notification_template::NotificationTemplateRenderInvocationError::Domain(_) => {
889            RuntimeFailure::ProtocolViolation {
890                capability: notification_template::CAPABILITY_ID,
891            }
892        }
893    }
894}
895
896fn authorized(context: &Ctx, allowed: &[String]) -> bool {
897    authorized_caller(context, allowed).is_some()
898}
899
900fn authorized_caller<'a>(context: &'a Ctx, allowed: &[String]) -> Option<&'a str> {
901    context
902        .caller_instance()
903        .filter(|caller| allowed.iter().any(|allowed| allowed == caller))
904}
905
906fn required_bounded(value: &str, minimum: usize, maximum: usize) -> bool {
907    let length = value.chars().count();
908    !value.trim().is_empty() && (minimum..=maximum).contains(&length)
909}
910
911fn optional_bounded(value: Option<&str>, maximum: usize) -> bool {
912    value.is_none_or(|value| value.chars().count() <= maximum)
913}
914
915fn valid_digest(value: &str) -> bool {
916    value.strip_prefix("sha256:").is_some_and(|digest| {
917        digest.len() == 64
918            && digest
919                .bytes()
920                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
921    })
922}
923
924fn valid_create_request(
925    request: &transactional::CreateOrganizationInvitationRequest,
926    now: DateTime<Utc>,
927) -> bool {
928    required_bounded(&request.source.entity_type, 1, 160)
929        && request.source.entity_type == "organization_invitation"
930        && required_bounded(&request.source.entity_id, 1, 240)
931        && required_bounded(&request.recipient.address, 3, 320)
932        && request.recipient.address.contains('@')
933        && optional_bounded(request.recipient.display_name.as_deref(), 240)
934        && required_bounded(&request.template.organization_id, 1, 240)
935        && required_bounded(&request.template.organization_name, 1, 240)
936        && required_bounded(&request.template.invitation_id, 1, 240)
937        && request.source.entity_id == request.template.invitation_id
938        && required_bounded(&request.template.invitation_url, 1, 4_096)
939        && (request.template.invitation_url.starts_with("https://")
940            || request
941                .template
942                .invitation_url
943                .starts_with("http://localhost"))
944        && optional_bounded(request.template.inviter_display_name.as_deref(), 240)
945        && optional_bounded(request.template.role_name.as_deref(), 160)
946        && valid_time(&request.template.expires_at)
947        && parse_time(&request.template.expires_at).is_ok_and(|expires_at| expires_at > now)
948        && required_bounded(&request.idempotency_key, 1, 240)
949        && required_bounded(&request.correlation_id, 1, 240)
950        && optional_bounded(request.causation_id.as_deref(), 240)
951        && optional_bounded(request.requested_by.as_deref(), 240)
952}
953
954fn valid_access_request_notification_request(
955    request: &transactional::CreateAccessRequestNotificationRequest,
956    now: DateTime<Utc>,
957) -> bool {
958    let event = match &request.event {
959        transactional::CreateAccessRequestNotificationRequestEvent::Submitted => "submitted",
960        transactional::CreateAccessRequestNotificationRequestEvent::Approved => "approved",
961        transactional::CreateAccessRequestNotificationRequestEvent::Denied => "denied",
962        transactional::CreateAccessRequestNotificationRequestEvent::Expiring => "expiring",
963    };
964    let expected_idempotency_key = format!("access-request:{}:{event}", request.request_id);
965    let expiry = request
966        .expires_at
967        .as_deref()
968        .and_then(|value| parse_time(value).ok());
969    let expiry_valid = match &request.event {
970        transactional::CreateAccessRequestNotificationRequestEvent::Expiring => {
971            expiry.is_some_and(|value| value > now)
972        }
973        transactional::CreateAccessRequestNotificationRequestEvent::Denied => {
974            request.expires_at.is_none()
975        }
976        _ => request.expires_at.is_none() || expiry.is_some_and(|value| value > now),
977    };
978    required_bounded(&request.request_id, 1, 160)
979        && required_bounded(&request.organization_id, 1, 240)
980        && required_bounded(&request.recipient.address, 3, 320)
981        && request.recipient.address.contains('@')
982        && optional_bounded(request.recipient.display_name.as_deref(), 240)
983        && required_bounded(&request.role.role_id, 1, 160)
984        && optional_bounded(request.role.display_name.as_deref(), 160)
985        && required_bounded(&request.scope.kind, 1, 160)
986        && required_bounded(&request.scope.id, 1, 240)
987        && optional_bounded(request.scope.display_name.as_deref(), 240)
988        && request.expires_at.as_deref().is_none_or(valid_time)
989        && expiry_valid
990        && request.idempotency_key == expected_idempotency_key
991        && required_bounded(&request.correlation_id, 1, 240)
992        && optional_bounded(request.causation_id.as_deref(), 240)
993        && optional_bounded(request.requested_by.as_deref(), 240)
994}
995
996fn valid_lifecycle_request(request: &transactional::ObserveInvitationLifecycleRequest) -> bool {
997    required_bounded(&request.observation_id, 1, 240)
998        && required_bounded(&request.organization_id, 1, 240)
999        && required_bounded(&request.invitation_id, 1, 240)
1000        && valid_time(&request.observed_at)
1001}
1002
1003fn valid_receipt_request(request: &delivery::ObserveReceiptRequest) -> bool {
1004    required_bounded(&request.observation_id, 1, 240)
1005        && required_bounded(&request.delivery_id, 1, 160)
1006        && required_bounded(&request.attempt_id, 1, 160)
1007        && required_bounded(&request.run_id, 1, 160)
1008        && valid_time(&request.observed_at)
1009        && required_bounded(&request.remote_id, 1, 320)
1010        && valid_digest(&request.digest)
1011}
1012
1013fn valid_list_request(request: &admin::ListDeliveriesRequest) -> bool {
1014    request.limit.is_none_or(|limit| (1..=200).contains(&limit))
1015        && request
1016            .cursor
1017            .as_deref()
1018            .is_none_or(|cursor| required_bounded(cursor, 1, 160))
1019}
1020
1021fn valid_retry_request(request: &admin::RetryDeliveryRequest) -> bool {
1022    required_bounded(&request.delivery_id, 1, 160)
1023        && (1..=MAX_SAFE_WIRE_INTEGER).contains(&request.revision)
1024        && required_bounded(&request.idempotency_key, 1, 240)
1025}
1026
1027fn intent_source(
1028    caller: String,
1029    source: transactional::CreateOrganizationInvitationRequestSource,
1030) -> IntentSource {
1031    IntentSource {
1032        module_id: caller,
1033        entity_type: source.entity_type,
1034        entity_id: source.entity_id,
1035    }
1036}
1037
1038fn receipt_observation(
1039    caller: String,
1040    request: &delivery::ObserveReceiptRequest,
1041    kind: ReceiptKind,
1042    observed_at: DateTime<Utc>,
1043) -> EmailReceiptObserved {
1044    EmailReceiptObserved {
1045        delivery_id: request.delivery_id.clone(),
1046        attempt_id: request.attempt_id.clone(),
1047        function_run_id: request.run_id.clone(),
1048        kind,
1049        source: caller,
1050        observed_at,
1051        remote_id: request.remote_id.clone(),
1052        digest: request.digest.clone(),
1053    }
1054}
1055
1056impl Lifecycle for NotificationPlugin {
1057    async fn prepare(&self, context: PrepareContext) -> Result<(), RuntimeFailure> {
1058        let dependencies = context.dependencies().clone();
1059        let cancellation = context.cancellation();
1060        let email_providers = email::EmailDispatchClient::many_from_dependencies(&dependencies)?;
1061        let [email_provider] = email_providers.as_slice() else {
1062            return Err(RuntimeFailure::InvalidResolvedPlan {
1063                detail: "Notification requires exactly one lenso.email-dispatch@1 Provider"
1064                    .to_owned(),
1065            });
1066        };
1067        let email_provider_instance = email_provider.provider_instance().to_owned();
1068        let secrets = lenso_capability_secrets::SecretsClient::from_dependencies(&dependencies)?;
1069        let database_context =
1070            dependencies.invocation_context_after(DEPENDENCY_TIMEOUT, cancellation.clone())?;
1071        let database_url =
1072            resolve_secret(&secrets, database_context, &self.config.database_url_secret).await?;
1073        let snapshot_context =
1074            dependencies.invocation_context_after(DEPENDENCY_TIMEOUT, cancellation)?;
1075        let snapshot_key =
1076            resolve_secret(&secrets, snapshot_context, &self.config.snapshot_key_secret).await?;
1077        let protector = AeadSnapshotProtector::from_base64_key(
1078            &snapshot_key,
1079            self.config.snapshot_key_secret.clone(),
1080        )
1081        .map_err(runtime)?;
1082        let postgres = OwnedPostgres::prepare(
1083            &database_url,
1084            schema_plan(self.config.schema.clone()).map_err(|error| {
1085                RuntimeFailure::InvalidResolvedPlan {
1086                    detail: error.to_string(),
1087                }
1088            })?,
1089        )
1090        .await
1091        .map_err(runtime)?;
1092        if let Err(error) = verify_managed_catalog(postgres.pool()).await {
1093            postgres.pool().close().await;
1094            return Err(runtime(error));
1095        }
1096        self.state.replace(Some(PreparedNotification {
1097            postgres,
1098            protector,
1099            email_provider_instance,
1100        }));
1101        Ok(())
1102    }
1103
1104    async fn deactivate(&self, _context: DeactivateContext) -> Result<(), RuntimeFailure> {
1105        let prepared = self.state.borrow_mut().take();
1106        if let Some(prepared) = prepared {
1107            prepared.postgres.pool().close().await;
1108        }
1109        Ok(())
1110    }
1111}
1112
1113fn dispatch_request(work: &DispatchWork) -> DispatchRequest {
1114    work.request.clone()
1115}
1116
1117fn dispatch_observation(
1118    work: &DispatchWork,
1119    email_provider_instance: &str,
1120    response: email::DispatchResponse,
1121) -> Result<EmailDispatchObserved, RuntimeFailure> {
1122    if !valid_dispatch_response(&response, email_provider_instance) {
1123        return Err(email_protocol_violation());
1124    }
1125    let observed_at = parse_time(&response.observed_at).map_err(|_| email_protocol_violation())?;
1126    let failure = response
1127        .failure
1128        .map(|failure| {
1129            Ok(SanitizedFailure {
1130                code: failure.code,
1131                classification: failure.classification,
1132                retry_after_ms: failure
1133                    .retry_after_ms
1134                    .map(u64::try_from)
1135                    .transpose()
1136                    .map_err(|_| email_protocol_violation())?,
1137            })
1138        })
1139        .transpose()?;
1140    Ok(EmailDispatchObserved {
1141        delivery_id: work.claim.delivery_id.clone(),
1142        attempt_id: work.claim.attempt_id.clone(),
1143        function_run_id: work.claim.run_id.clone(),
1144        outcome: match response.outcome {
1145            DispatchResponseOutcome::Accepted => DispatchOutcome::Accepted,
1146            DispatchResponseOutcome::TemporaryFailure => DispatchOutcome::TemporaryFailure,
1147            DispatchResponseOutcome::PermanentFailure => DispatchOutcome::PermanentFailure,
1148            DispatchResponseOutcome::DeliveryUnknown => DispatchOutcome::DeliveryUnknown,
1149        },
1150        provider: email_provider_instance.to_owned(),
1151        observed_at,
1152        remote_receipt: response.remote_receipt.map(|receipt| RemoteReceiptSummary {
1153            remote_id: receipt.remote_id,
1154            source: email_provider_instance.to_owned(),
1155            digest: receipt.digest,
1156        }),
1157        failure,
1158    })
1159}
1160
1161fn valid_dispatch_response(
1162    response: &email::DispatchResponse,
1163    email_provider_instance: &str,
1164) -> bool {
1165    if response.provider != email_provider_instance || !valid_time(&response.observed_at) {
1166        return false;
1167    }
1168    let remote_receipt_valid = response.remote_receipt.as_ref().is_none_or(|receipt| {
1169        required_bounded(&receipt.remote_id, 1, 320)
1170            && receipt.source == email_provider_instance
1171            && valid_digest(&receipt.digest)
1172    });
1173    if !remote_receipt_valid {
1174        return false;
1175    }
1176    match &response.outcome {
1177        DispatchResponseOutcome::Accepted => response.failure.is_none(),
1178        DispatchResponseOutcome::TemporaryFailure => {
1179            response.remote_receipt.is_none()
1180                && response.failure.as_ref().is_some_and(|failure| {
1181                    valid_dispatch_failure(failure, "temporary_failure")
1182                        && failure
1183                            .retry_after_ms
1184                            .is_none_or(|delay| (0..=86_400_000).contains(&delay))
1185                })
1186        }
1187        DispatchResponseOutcome::PermanentFailure => {
1188            response.remote_receipt.is_none()
1189                && response.failure.as_ref().is_some_and(|failure| {
1190                    valid_dispatch_failure(failure, "permanent_failure")
1191                        && failure.retry_after_ms.is_none()
1192                })
1193        }
1194        DispatchResponseOutcome::DeliveryUnknown => {
1195            response.remote_receipt.is_none()
1196                && response.failure.as_ref().is_some_and(|failure| {
1197                    valid_dispatch_failure(failure, "delivery_unknown")
1198                        && failure.retry_after_ms.is_none()
1199                })
1200        }
1201    }
1202}
1203
1204fn valid_dispatch_failure(failure: &email::DispatchResponseFailure, classification: &str) -> bool {
1205    required_bounded(&failure.code, 1, 160)
1206        && required_bounded(&failure.classification, 1, 160)
1207        && failure.classification == classification
1208}
1209
1210fn email_protocol_violation() -> RuntimeFailure {
1211    RuntimeFailure::ProtocolViolation {
1212        capability: email::CAPABILITY_ID,
1213    }
1214}
1215
1216fn permanent_rejection(
1217    work: &DispatchWork,
1218    email_provider_instance: &str,
1219    code: &str,
1220    observed_at: DateTime<Utc>,
1221) -> EmailDispatchObserved {
1222    EmailDispatchObserved {
1223        delivery_id: work.claim.delivery_id.clone(),
1224        attempt_id: work.claim.attempt_id.clone(),
1225        function_run_id: work.claim.run_id.clone(),
1226        outcome: DispatchOutcome::PermanentFailure,
1227        provider: email_provider_instance.to_owned(),
1228        observed_at,
1229        remote_receipt: None,
1230        failure: Some(SanitizedFailure {
1231            code: code.to_owned(),
1232            classification: "permanent_failure".to_owned(),
1233            retry_after_ms: None,
1234        }),
1235    }
1236}
1237
1238fn unknown_dispatch(
1239    work: &DispatchWork,
1240    email_provider_instance: &str,
1241    observed_at: DateTime<Utc>,
1242    code: &str,
1243) -> EmailDispatchObserved {
1244    EmailDispatchObserved {
1245        delivery_id: work.claim.delivery_id.clone(),
1246        attempt_id: work.claim.attempt_id.clone(),
1247        function_run_id: work.claim.run_id.clone(),
1248        outcome: DispatchOutcome::DeliveryUnknown,
1249        provider: email_provider_instance.to_owned(),
1250        observed_at,
1251        remote_receipt: None,
1252        failure: Some(SanitizedFailure {
1253            code: code.to_owned(),
1254            classification: "delivery_unknown".to_owned(),
1255            retry_after_ms: None,
1256        }),
1257    }
1258}
1259
1260async fn apply_dispatch(
1261    pool: &sqlx::PgPool,
1262    work: &DispatchWork,
1263    observed: &EmailDispatchObserved,
1264) -> Result<(), NotificationError> {
1265    apply_observation(
1266        pool,
1267        ObservationEnvelope {
1268            id: format!("dispatch-observation:{}", work.claim.attempt_id),
1269            event_name: EMAIL_DISPATCH_OBSERVED_EVENT.to_owned(),
1270            event_version: 1,
1271            source_module: observed.provider.clone(),
1272            aggregate_id: work.claim.delivery_id.clone(),
1273            occurred_at: observed.observed_at,
1274            payload: serde_json::to_value(observed).map_err(|error| {
1275                NotificationError::new(
1276                    ErrorCode::Internal,
1277                    "Notification dispatch observation encoding failed",
1278                )
1279                .with_source(error)
1280            })?,
1281        },
1282    )
1283    .await
1284}
1285
1286async fn apply_observation(
1287    pool: &sqlx::PgPool,
1288    envelope: ObservationEnvelope,
1289) -> Result<(), NotificationError> {
1290    NotificationEventApplier::new(pool.clone())
1291        .apply(&envelope)
1292        .await
1293}
1294
1295fn delivery_status(status: &str) -> Result<delivery::DispatchDueResponseStatus, RuntimeFailure> {
1296    match status {
1297        "accepted" => Ok(delivery::DispatchDueResponseStatus::Accepted),
1298        "retry_scheduled" => Ok(delivery::DispatchDueResponseStatus::RetryScheduled),
1299        "failed" => Ok(delivery::DispatchDueResponseStatus::Failed),
1300        "delivery_unknown" => Ok(delivery::DispatchDueResponseStatus::DeliveryUnknown),
1301        other => Err(runtime(format!(
1302            "stored delivery status `{other}` is invalid after dispatch"
1303        ))),
1304    }
1305}
1306
1307fn admin_detail(value: DeliveryDetail) -> Result<admin::GetDeliveryResponse, RuntimeFailure> {
1308    if value.attempts.len() > ADMIN_ATTEMPT_LIMIT
1309        || value.receipts.len() > ADMIN_RECEIPT_LIMIT
1310        || value.retry_requests.len() > ADMIN_RETRY_REQUEST_LIMIT
1311    {
1312        return Err(invalid_admin_projection());
1313    }
1314    let correlation_id = value.delivery.correlation_id.clone();
1315    Ok(admin::GetDeliveryResponse {
1316        attempts: value
1317            .attempts
1318            .into_iter()
1319            .map(admin_attempt)
1320            .collect::<Result<Vec<_>, _>>()?,
1321        delivery: admin_delivery(value.delivery)?,
1322        open_in_story_correlation_id: correlation_id,
1323        receipts: value
1324            .receipts
1325            .into_iter()
1326            .map(admin_receipt)
1327            .collect::<Result<Vec<_>, _>>()?,
1328        retry_requests: value
1329            .retry_requests
1330            .into_iter()
1331            .map(admin_retry_record)
1332            .collect::<Result<Vec<_>, _>>()?,
1333    })
1334}
1335
1336fn admin_delivery(value: DeliverySummary) -> Result<admin::Delivery, RuntimeFailure> {
1337    if !required_bounded(&value.id, 1, 160)
1338        || !required_bounded(&value.recipient_mask, 3, 320)
1339        || !required_bounded(&value.template_id, 1, 160)
1340        || !required_bounded(&value.template_version, 1, 80)
1341        || !required_bounded(&value.locale, 2, 32)
1342        || !(1..=MAX_SAFE_WIRE_INTEGER).contains(&value.revision)
1343        || !(0..=10).contains(&value.attempt_count)
1344        || !(1..=10).contains(&value.max_attempts)
1345        || value.attempt_count > value.max_attempts
1346        || value.redacted_preview.chars().count() > 160
1347        || !valid_digest(&value.content_digest)
1348        || !required_bounded(&value.correlation_id, 1, 240)
1349        || !optional_bounded(value.final_reason.as_deref(), 160)
1350    {
1351        return Err(invalid_admin_projection());
1352    }
1353    let status = match value.status.as_str() {
1354        "queued" => admin::DeliveryStatus::Queued,
1355        "attempting" => admin::DeliveryStatus::Attempting,
1356        "accepted" => admin::DeliveryStatus::Accepted,
1357        "retry_scheduled" => admin::DeliveryStatus::RetryScheduled,
1358        "delivered" => admin::DeliveryStatus::Delivered,
1359        "failed" => admin::DeliveryStatus::Failed,
1360        "delivery_unknown" => admin::DeliveryStatus::DeliveryUnknown,
1361        other => {
1362            let _ = other;
1363            return Err(invalid_admin_projection());
1364        }
1365    };
1366    Ok(admin::Delivery {
1367        attempt_count: i64::from(value.attempt_count),
1368        content_digest: value.content_digest,
1369        correlation_id: value.correlation_id,
1370        created_at: format_time(value.created_at)?,
1371        final_reason: value.final_reason,
1372        id: value.id,
1373        locale: value.locale,
1374        max_attempts: i64::from(value.max_attempts),
1375        next_attempt_at: value.next_attempt_at.map(format_time).transpose()?,
1376        recipient_mask: value.recipient_mask,
1377        redacted_preview: value.redacted_preview,
1378        retry_now_eligible: value.status == "retry_scheduled"
1379            && value.attempt_count < value.max_attempts,
1380        revision: value.revision,
1381        status,
1382        template_id: value.template_id,
1383        template_version: value.template_version,
1384        updated_at: format_time(value.updated_at)?,
1385    })
1386}
1387
1388fn admin_attempt(value: AttemptRecord) -> Result<admin::Attempt, RuntimeFailure> {
1389    if !required_bounded(&value.id, 1, 160)
1390        || !(1..=10).contains(&value.sequence)
1391        || !required_bounded(&value.function_run_id, 1, 160)
1392        || !optional_bounded(value.provider.as_deref(), 160)
1393        || !optional_bounded(value.remote_receipt_id.as_deref(), 320)
1394        || !optional_bounded(value.failure_code.as_deref(), 160)
1395        || !optional_bounded(value.failure_classification.as_deref(), 160)
1396    {
1397        return Err(invalid_admin_projection());
1398    }
1399    let status = match value.status.as_str() {
1400        "dispatching" => admin::AttemptStatus::Dispatching,
1401        "accepted" => admin::AttemptStatus::Accepted,
1402        "temporary_failure" => admin::AttemptStatus::TemporaryFailure,
1403        "permanent_failure" => admin::AttemptStatus::PermanentFailure,
1404        "delivery_unknown" => admin::AttemptStatus::DeliveryUnknown,
1405        _ => return Err(invalid_admin_projection()),
1406    };
1407    Ok(admin::Attempt {
1408        completed_at: value.completed_at.map(format_time).transpose()?,
1409        failure_classification: value.failure_classification,
1410        failure_code: value.failure_code,
1411        id: value.id,
1412        provider: value.provider,
1413        remote_receipt_id: value.remote_receipt_id,
1414        run_id: value.function_run_id,
1415        sequence: i64::from(value.sequence),
1416        started_at: format_time(value.started_at)?,
1417        status,
1418    })
1419}
1420
1421fn admin_receipt(value: ReceiptRecord) -> Result<admin::Receipt, RuntimeFailure> {
1422    if !required_bounded(&value.id, 1, 160)
1423        || !required_bounded(&value.attempt_id, 1, 160)
1424        || !required_bounded(&value.source, 1, 160)
1425        || !required_bounded(&value.remote_id, 1, 320)
1426        || !valid_digest(&value.digest)
1427    {
1428        return Err(invalid_admin_projection());
1429    }
1430    let kind = match value.kind.as_str() {
1431        "accepted" => admin::ReceiptKind::Accepted,
1432        "delivered" => admin::ReceiptKind::Delivered,
1433        "bounced" => admin::ReceiptKind::Bounced,
1434        "rejected" => admin::ReceiptKind::Rejected,
1435        _ => return Err(invalid_admin_projection()),
1436    };
1437    Ok(admin::Receipt {
1438        attempt_id: value.attempt_id,
1439        digest: value.digest,
1440        id: value.id,
1441        kind,
1442        observed_at: format_time(value.observed_at)?,
1443        remote_id: value.remote_id,
1444        source: value.source,
1445    })
1446}
1447
1448fn admin_retry_record(value: RetryRecord) -> Result<admin::RetryRecord, RuntimeFailure> {
1449    if !required_bounded(&value.id, 1, 160)
1450        || !optional_bounded(value.requested_by.as_deref(), 240)
1451        || !(1..=MAX_SAFE_WIRE_INTEGER).contains(&value.source_revision)
1452        || !optional_bounded(value.reason.as_deref(), 160)
1453    {
1454        return Err(invalid_admin_projection());
1455    }
1456    let kind = match value.kind.as_str() {
1457        "automatic" => admin::RetryRecordKind::Automatic,
1458        "manual" => admin::RetryRecordKind::Manual,
1459        _ => return Err(invalid_admin_projection()),
1460    };
1461    let decision = match value.decision.as_str() {
1462        "scheduled" => admin::RetryRecordDecision::Scheduled,
1463        "rejected" => admin::RetryRecordDecision::Rejected,
1464        _ => return Err(invalid_admin_projection()),
1465    };
1466    Ok(admin::RetryRecord {
1467        created_at: format_time(value.created_at)?,
1468        decision,
1469        id: value.id,
1470        kind,
1471        reason: value.reason,
1472        requested_by: value.requested_by,
1473        scheduled_at: value.scheduled_at.map(format_time).transpose()?,
1474        source_revision: value.source_revision,
1475    })
1476}
1477
1478fn admin_retry(value: RetryResult) -> Result<admin::RetryDeliveryResponse, RuntimeFailure> {
1479    if !required_bounded(&value.delivery_id, 1, 160)
1480        || !(1..=MAX_SAFE_WIRE_INTEGER).contains(&value.revision)
1481        || value.status != "retry_scheduled"
1482    {
1483        return Err(invalid_admin_projection());
1484    }
1485    Ok(admin::RetryDeliveryResponse {
1486        delivery_id: value.delivery_id,
1487        idempotent_replay: value.idempotent_replay,
1488        revision: value.revision,
1489        scheduled_at: format_time(value.scheduled_at)?,
1490        status: admin::RetryDeliveryResponseStatus::RetryScheduled,
1491    })
1492}
1493
1494fn invalid_admin_projection() -> RuntimeFailure {
1495    runtime("stored Notification Admin projection violates its bounded Capability Contract")
1496}
1497
1498fn admin_status(status: &admin::DeliveryStatus) -> &'static str {
1499    match status {
1500        admin::DeliveryStatus::Queued => "queued",
1501        admin::DeliveryStatus::Attempting => "attempting",
1502        admin::DeliveryStatus::Accepted => "accepted",
1503        admin::DeliveryStatus::RetryScheduled => "retry_scheduled",
1504        admin::DeliveryStatus::Delivered => "delivered",
1505        admin::DeliveryStatus::Failed => "failed",
1506        admin::DeliveryStatus::DeliveryUnknown => "delivery_unknown",
1507    }
1508}
1509
1510fn map_create_error(
1511    error: NotificationError,
1512) -> PluginError<transactional::CreateOrganizationInvitationError> {
1513    match error.code {
1514        ErrorCode::Validation => {
1515            PluginError::domain(transactional::CreateOrganizationInvitationError::InvalidIntent)
1516        }
1517        ErrorCode::Conflict => PluginError::domain(
1518            transactional::CreateOrganizationInvitationError::IdempotencyConflict,
1519        ),
1520        ErrorCode::NotFound | ErrorCode::EvidenceOverflow | ErrorCode::Internal => {
1521            PluginError::runtime(runtime(error))
1522        }
1523    }
1524}
1525
1526fn map_access_request_create_error(
1527    error: NotificationError,
1528) -> PluginError<transactional::CreateAccessRequestNotificationError> {
1529    match error.code {
1530        ErrorCode::Validation => {
1531            PluginError::domain(transactional::CreateAccessRequestNotificationError::InvalidIntent)
1532        }
1533        ErrorCode::Conflict => PluginError::domain(
1534            transactional::CreateAccessRequestNotificationError::IdempotencyConflict,
1535        ),
1536        ErrorCode::NotFound | ErrorCode::EvidenceOverflow | ErrorCode::Internal => {
1537            PluginError::runtime(runtime(error))
1538        }
1539    }
1540}
1541
1542fn map_lifecycle_error(
1543    error: NotificationError,
1544) -> PluginError<transactional::ObserveInvitationLifecycleError> {
1545    match error.code {
1546        ErrorCode::Validation => {
1547            PluginError::domain(transactional::ObserveInvitationLifecycleError::InvalidObservation)
1548        }
1549        ErrorCode::Conflict => {
1550            PluginError::domain(transactional::ObserveInvitationLifecycleError::ObservationConflict)
1551        }
1552        ErrorCode::NotFound | ErrorCode::EvidenceOverflow | ErrorCode::Internal => {
1553            PluginError::runtime(runtime(error))
1554        }
1555    }
1556}
1557
1558fn map_receipt_error(error: NotificationError) -> PluginError<delivery::ObserveReceiptError> {
1559    match error.code {
1560        ErrorCode::Validation => PluginError::domain(delivery::ObserveReceiptError::InvalidReceipt),
1561        ErrorCode::NotFound => PluginError::domain(delivery::ObserveReceiptError::DeliveryNotFound),
1562        ErrorCode::Conflict => PluginError::domain(delivery::ObserveReceiptError::ReceiptConflict),
1563        ErrorCode::EvidenceOverflow | ErrorCode::Internal => PluginError::runtime(runtime(error)),
1564    }
1565}
1566
1567fn map_get_error(error: NotificationError) -> PluginError<admin::GetDeliveryError> {
1568    match error.code {
1569        ErrorCode::EvidenceOverflow => {
1570            PluginError::domain(admin::GetDeliveryError::EvidenceOverflow)
1571        }
1572        ErrorCode::Validation | ErrorCode::NotFound | ErrorCode::Conflict | ErrorCode::Internal => {
1573            PluginError::runtime(runtime(error))
1574        }
1575    }
1576}
1577
1578fn map_retry_error(error: NotificationError) -> PluginError<admin::RetryDeliveryError> {
1579    match error.code {
1580        ErrorCode::NotFound => PluginError::domain(admin::RetryDeliveryError::DeliveryNotFound),
1581        ErrorCode::Conflict if error.message().contains("revision is stale") => {
1582            PluginError::domain(admin::RetryDeliveryError::StaleRevision)
1583        }
1584        ErrorCode::Conflict if error.message().contains("not eligible") => {
1585            PluginError::domain(admin::RetryDeliveryError::RetryNotAllowed)
1586        }
1587        ErrorCode::Conflict => PluginError::domain(admin::RetryDeliveryError::IdempotencyConflict),
1588        ErrorCode::Validation | ErrorCode::EvidenceOverflow | ErrorCode::Internal => {
1589            PluginError::runtime(runtime(error))
1590        }
1591    }
1592}
1593
1594async fn resolve_secret(
1595    secrets: &lenso_capability_secrets::SecretsClient,
1596    context: Ctx,
1597    reference: &str,
1598) -> Result<Zeroizing<String>, RuntimeFailure> {
1599    secrets
1600        .resolve_with_context(
1601            context,
1602            ResolveRequest {
1603                reference: reference.to_owned(),
1604            },
1605        )
1606        .await
1607        .map(|response| Zeroizing::new(response.value))
1608        .map_err(|error| match error {
1609            SecretsInvocationError::Domain(_) => RuntimeFailure::PluginFailure {
1610                detail: format!("secret `{reference}` was rejected"),
1611            },
1612            SecretsInvocationError::Runtime(error) => error,
1613        })
1614}
1615
1616fn valid_secret_reference(value: &str) -> bool {
1617    !value.is_empty()
1618        && value.len() <= 256
1619        && !value.starts_with('/')
1620        && !value.ends_with('/')
1621        && value.split('/').all(|segment| {
1622            !segment.is_empty()
1623                && segment != "."
1624                && segment != ".."
1625                && segment
1626                    .bytes()
1627                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1628        })
1629}
1630
1631fn valid_instance(value: &str) -> bool {
1632    !value.is_empty()
1633        && value.len() <= 160
1634        && value
1635            .bytes()
1636            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
1637}
1638
1639fn parse_time(value: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
1640    DateTime::parse_from_rfc3339(value).map(|value| value.with_timezone(&Utc))
1641}
1642
1643fn valid_time(value: &str) -> bool {
1644    required_bounded(value, 1, 64) && parse_time(value).is_ok()
1645}
1646
1647pub(crate) fn format_time(value: DateTime<Utc>) -> Result<String, RuntimeFailure> {
1648    if !(0..=9_999).contains(&value.year()) {
1649        return Err(runtime(
1650            "stored Notification timestamp is outside the RFC 3339 four-digit year range",
1651        ));
1652    }
1653    let formatted = value.to_rfc3339_opts(SecondsFormat::Millis, true);
1654    if !valid_time(&formatted) {
1655        return Err(runtime(
1656            "stored Notification timestamp violates its bounded Capability Contract",
1657        ));
1658    }
1659    Ok(formatted)
1660}
1661
1662fn runtime(error: impl fmt::Display) -> RuntimeFailure {
1663    RuntimeFailure::PluginFailure {
1664        detail: error.to_string(),
1665    }
1666}
1667
1668#[cfg(test)]
1669mod tests {
1670    use std::{cell::RefCell, rc::Rc, time::Duration};
1671
1672    use lenso_app_plan::{
1673        AppComposition, CapabilityBinding, CapabilityEndpointPlan, CapabilityRequirementPlan,
1674        PluginInstancePlan, ResolvedAppPlan,
1675    };
1676    use lenso_kernel::{
1677        ActivateContext, CancellationToken, DeterministicDriver, InvocationContext, Kernel,
1678        NativeRequestEndpoint, NativeRequestFuture, PluginFuture, PluginLifecycle, RuntimeFailure,
1679        ShutdownOutcome,
1680    };
1681    use lenso_native_adapter::{
1682        NativePluginFactory, NativePluginFactoryContext, NativePluginInstance, NativePluginRegistry,
1683    };
1684
1685    use super::*;
1686    use crate::domain::DeliveryStatus as StoredDeliveryStatus;
1687
1688    const CONSUMER_PACKAGE_ID: &str = "test.notification-consumer";
1689    const FIXTURE_PROVIDER_PACKAGE_ID: &str = "test.notification-transactional-provider";
1690    const EMPTY_PACKAGE_ID: &str = "test.without-notification";
1691    const SECRETS_PACKAGE_ID: &str = "test.notification-secrets";
1692    const EMAIL_PACKAGE_ID: &str = "test.notification-email-dispatch";
1693    const TEMPLATE_PACKAGE_ID: &str = "test.notification-template";
1694
1695    #[derive(Clone, Copy, Debug)]
1696    enum FixtureOutcome {
1697        Success,
1698        Domain,
1699        Runtime,
1700    }
1701
1702    type InvocationResult = Result<
1703        transactional::CreateOrganizationInvitationResponse,
1704        transactional::TransactionalCreateOrganizationInvitationInvocationError,
1705    >;
1706
1707    #[derive(Clone, Debug)]
1708    struct FixtureTransactionalProvider {
1709        outcome: FixtureOutcome,
1710    }
1711
1712    impl transactional::TransactionalProvider for FixtureTransactionalProvider {
1713        fn create_access_request_notification(
1714            &self,
1715            _context: InvocationContext,
1716            _request: transactional::CreateAccessRequestNotificationRequest,
1717        ) -> NativeRequestFuture<transactional::TransactionalCreateAccessRequestNotification>
1718        {
1719            Box::pin(std::future::ready(Ok(Ok(
1720                transactional::CreateAccessRequestNotificationResponse {
1721                    delivery_id: "ntf_dlv_access_fixture".to_owned(),
1722                    idempotent_replay: false,
1723                    intent_id: "ntf_int_access_fixture".to_owned(),
1724                    status: transactional::CreateAccessRequestNotificationResponseStatus::Queued,
1725                },
1726            ))))
1727        }
1728
1729        fn create_organization_invitation(
1730            &self,
1731            _context: InvocationContext,
1732            _request: transactional::CreateOrganizationInvitationRequest,
1733        ) -> NativeRequestFuture<transactional::TransactionalCreateOrganizationInvitation> {
1734            let result = match self.outcome {
1735                FixtureOutcome::Success => {
1736                    Ok(Ok(transactional::CreateOrganizationInvitationResponse {
1737                        delivery_id: "ntf_dlv_fixture".to_owned(),
1738                        idempotent_replay: false,
1739                        intent_id: "ntf_int_fixture".to_owned(),
1740                        status: transactional::CreateOrganizationInvitationResponseStatus::Queued,
1741                    }))
1742                }
1743                FixtureOutcome::Domain => Ok(Err(
1744                    transactional::CreateOrganizationInvitationError::Unauthorized,
1745                )),
1746                FixtureOutcome::Runtime => Err(RuntimeFailure::PluginFailure {
1747                    detail: "fixture notification storage unavailable".to_owned(),
1748                }),
1749            };
1750            Box::pin(std::future::ready(result))
1751        }
1752
1753        fn observe_invitation_lifecycle(
1754            &self,
1755            _context: InvocationContext,
1756            _request: transactional::ObserveInvitationLifecycleRequest,
1757        ) -> NativeRequestFuture<transactional::TransactionalObserveInvitationLifecycle> {
1758            Box::pin(std::future::ready(Ok(Ok(
1759                transactional::ObserveInvitationLifecycleResponse { recorded: true },
1760            ))))
1761        }
1762    }
1763
1764    #[derive(Clone, Debug)]
1765    struct FixtureProviderFactory {
1766        outcome: FixtureOutcome,
1767    }
1768
1769    impl NativePluginFactory for FixtureProviderFactory {
1770        fn package_id(&self) -> &'static str {
1771            FIXTURE_PROVIDER_PACKAGE_ID
1772        }
1773
1774        fn instantiate(
1775            &self,
1776            _context: NativePluginFactoryContext<'_>,
1777        ) -> Result<NativePluginInstance, RuntimeFailure> {
1778            let endpoint = Rc::new(transactional::TransactionalEndpoint::new(
1779                FixtureTransactionalProvider {
1780                    outcome: self.outcome,
1781                },
1782            )) as Rc<dyn NativeRequestEndpoint>;
1783            Ok(NativePluginInstance::new(vec![endpoint]))
1784        }
1785    }
1786
1787    #[derive(Clone, Debug)]
1788    struct ConsumerFactory {
1789        observed: Rc<RefCell<Option<InvocationResult>>>,
1790    }
1791
1792    impl NativePluginFactory for ConsumerFactory {
1793        fn package_id(&self) -> &'static str {
1794            CONSUMER_PACKAGE_ID
1795        }
1796
1797        fn instantiate(
1798            &self,
1799            _context: NativePluginFactoryContext<'_>,
1800        ) -> Result<NativePluginInstance, RuntimeFailure> {
1801            Ok(NativePluginInstance::with_lifecycle(
1802                Vec::new(),
1803                ConsumerLifecycle {
1804                    observed: self.observed.clone(),
1805                },
1806            ))
1807        }
1808    }
1809
1810    #[derive(Clone, Debug)]
1811    struct ConsumerLifecycle {
1812        observed: Rc<RefCell<Option<InvocationResult>>>,
1813    }
1814
1815    impl PluginLifecycle for ConsumerLifecycle {
1816        fn activate(&self, context: ActivateContext) -> PluginFuture {
1817            let client =
1818                transactional::TransactionalClient::from_dependencies(context.dependencies());
1819            let observed = self.observed.clone();
1820            Box::pin(async move {
1821                let client = client?;
1822                observed.replace(Some(
1823                    client
1824                        .create_organization_invitation(generated_request())
1825                        .await,
1826                ));
1827                Ok(())
1828            })
1829        }
1830    }
1831
1832    #[derive(Clone, Copy, Debug)]
1833    struct EmptyFactory(&'static str);
1834
1835    impl NativePluginFactory for EmptyFactory {
1836        fn package_id(&self) -> &'static str {
1837            self.0
1838        }
1839
1840        fn instantiate(
1841            &self,
1842            _context: NativePluginFactoryContext<'_>,
1843        ) -> Result<NativePluginInstance, RuntimeFailure> {
1844            Ok(NativePluginInstance::default())
1845        }
1846    }
1847
1848    #[test]
1849    fn generated_descriptor_declares_business_roles_and_exact_dependencies() {
1850        let descriptor: serde_json::Value = serde_json::from_str(PLUGIN_DESCRIPTOR_JSON)
1851            .expect("generated Plugin descriptor must be JSON");
1852        assert_eq!(PACKAGE_ID, "lenso.notification");
1853        assert_eq!(descriptor["plugin_id"], PACKAGE_ID);
1854        assert_eq!(descriptor["root_slot"], "notifications");
1855        let provided = descriptor["provided_capabilities"]
1856            .as_array()
1857            .expect("provided capabilities")
1858            .iter()
1859            .map(|entry| entry["capability_id"].as_str().expect("capability id"))
1860            .collect::<Vec<_>>();
1861        assert_eq!(
1862            provided,
1863            vec![
1864                transactional::CAPABILITY_ID,
1865                delivery::CAPABILITY_ID,
1866                admin::CAPABILITY_ID,
1867            ]
1868        );
1869        let required = descriptor["required_capabilities"]
1870            .as_array()
1871            .expect("required capabilities")
1872            .iter()
1873            .map(|entry| entry["capability_id"].as_str().expect("capability id"))
1874            .collect::<Vec<_>>();
1875        assert_eq!(
1876            required,
1877            vec![
1878                lenso_capability_secrets::CAPABILITY_ID,
1879                email::CAPABILITY_ID,
1880                notification_template::CAPABILITY_ID,
1881            ]
1882        );
1883        let linked = NativePluginRegistry::new()
1884            .with_linked_factories()
1885            .factories()
1886            .filter(|factory| factory.package_id() == PACKAGE_ID)
1887            .count();
1888        assert_eq!(linked, 1);
1889    }
1890
1891    #[test]
1892    fn template_requests_are_exact_versioned_and_event_typed() {
1893        let expires_at = parse_time("2026-09-01T00:00:00Z").expect("timestamp");
1894        let invitation = CreateTransactionalEmailIntent {
1895            source: IntentSource {
1896                module_id: "organization-blue".to_owned(),
1897                entity_type: "organization_invitation".to_owned(),
1898                entity_id: "invite_1".to_owned(),
1899            },
1900            recipient: EmailRecipient {
1901                address: "member@example.com".to_owned(),
1902                display_name: Some(" Member ".to_owned()),
1903                locale: "en-US".to_owned(),
1904            },
1905            template: OrganizationInvitationTemplateV1 {
1906                organization_id: "org_1".to_owned(),
1907                organization_name: " Acme ".to_owned(),
1908                invitation_id: "invite_1".to_owned(),
1909                invitation_url: "https://example.test/invite".to_owned(),
1910                inviter_display_name: None,
1911                role_name: Some(" Member ".to_owned()),
1912                expires_at,
1913            },
1914            idempotency_key: "organization-invitation:invite_1".to_owned(),
1915            correlation_id: "corr_1".to_owned(),
1916            causation_id: None,
1917            requested_by: None,
1918        };
1919        let request = organization_invitation_render_request(&invitation);
1920        assert_eq!(request.template_id, "organization-invitation");
1921        assert_eq!(request.version.as_deref(), Some("v1"));
1922        assert_eq!(request.locale, "en-US");
1923        let variables = request
1924            .variables
1925            .into_iter()
1926            .map(|item| (item.name, item.value))
1927            .collect::<std::collections::BTreeMap<_, _>>();
1928        assert_eq!(variables.len(), 7);
1929        assert_eq!(variables["organization_name"], "Acme");
1930        assert_eq!(variables["recipient_display_name"], "Member");
1931        assert_eq!(variables["inviter_display_name"], "");
1932        assert_eq!(variables["expires_at"], "2026-09-01T00:00:00Z");
1933
1934        let mut access = CreateAccessRequestNotificationIntent {
1935            source: IntentSource {
1936                module_id: "access-request-blue".to_owned(),
1937                entity_type: "access_request".to_owned(),
1938                entity_id: "ar_1".to_owned(),
1939            },
1940            recipient: EmailRecipient {
1941                address: "member@example.com".to_owned(),
1942                display_name: None,
1943                locale: "en".to_owned(),
1944            },
1945            template: AccessRequestNotificationTemplateV1 {
1946                request_id: "ar_1".to_owned(),
1947                organization_id: "org_1".to_owned(),
1948                event: AccessRequestNotificationEvent::Submitted,
1949                role: AccessRequestRoleV1 {
1950                    role_id: "role_member".to_owned(),
1951                    display_name: None,
1952                },
1953                scope: AccessRequestScopeV1 {
1954                    kind: "organization".to_owned(),
1955                    id: "org_1".to_owned(),
1956                    display_name: None,
1957                },
1958                expires_at: Some(expires_at),
1959            },
1960            idempotency_key: "access-request:ar_1:submitted".to_owned(),
1961            correlation_id: "corr_2".to_owned(),
1962            causation_id: None,
1963            requested_by: None,
1964        };
1965        let submitted = access_request_render_request(&access);
1966        assert_eq!(submitted.template_id, "access-request-submitted");
1967        assert!(
1968            submitted
1969                .variables
1970                .iter()
1971                .any(|item| item.name == "expires_at")
1972        );
1973
1974        access.template.event = AccessRequestNotificationEvent::Denied;
1975        access.template.expires_at = None;
1976        let denied = access_request_render_request(&access);
1977        assert_eq!(denied.template_id, "access-request-denied");
1978        assert!(
1979            denied
1980                .variables
1981                .iter()
1982                .all(|item| item.name != "expires_at")
1983        );
1984    }
1985
1986    #[test]
1987    fn template_render_failures_preserve_runtime_and_fail_closed_on_domain_results() {
1988        let runtime = RuntimeFailure::Unavailable {
1989            capability: notification_template::CAPABILITY_ID,
1990        };
1991        assert!(matches!(
1992            map_template_render_error(
1993                notification_template::NotificationTemplateRenderInvocationError::Runtime(runtime)
1994            ),
1995            RuntimeFailure::Unavailable { capability }
1996                if capability == notification_template::CAPABILITY_ID
1997        ));
1998        assert!(matches!(
1999            map_template_render_error(
2000                notification_template::NotificationTemplateRenderInvocationError::Domain(
2001                    notification_template::RenderError::NotFound,
2002                )
2003            ),
2004            RuntimeFailure::PluginFailure { .. }
2005        ));
2006        assert!(matches!(
2007            map_template_render_error(
2008                notification_template::NotificationTemplateRenderInvocationError::Domain(
2009                    notification_template::RenderError::UnexpectedVariable,
2010                )
2011            ),
2012            RuntimeFailure::ProtocolViolation { capability }
2013                if capability == notification_template::CAPABILITY_ID
2014        ));
2015    }
2016
2017    #[test]
2018    fn generated_client_provider_and_plan_preserve_success_domain_and_runtime_lanes() {
2019        let success = run_generated_fixture(FixtureOutcome::Success);
2020        assert!(matches!(
2021            success,
2022            Ok(transactional::CreateOrganizationInvitationResponse {
2023                status: transactional::CreateOrganizationInvitationResponseStatus::Queued,
2024                ..
2025            })
2026        ));
2027        let domain = run_generated_fixture(FixtureOutcome::Domain);
2028        assert!(matches!(
2029            domain,
2030            Err(
2031                transactional::TransactionalCreateOrganizationInvitationInvocationError::Domain(
2032                    transactional::CreateOrganizationInvitationError::Unauthorized
2033                )
2034            )
2035        ));
2036        let runtime = run_generated_fixture(FixtureOutcome::Runtime);
2037        assert!(matches!(
2038            runtime,
2039            Err(
2040                transactional::TransactionalCreateOrganizationInvitationInvocationError::Runtime(
2041                    RuntimeFailure::PluginFailure { detail }
2042                )
2043            ) if detail == "fixture notification storage unavailable"
2044        ));
2045    }
2046
2047    #[test]
2048    fn caller_identity_is_derived_and_cannot_be_spoofed_by_payload() {
2049        let request = generated_request();
2050        let source = intent_source("organization-blue".to_owned(), request.source);
2051        assert_eq!(source.module_id, "organization-blue");
2052
2053        let transactional_schema: serde_json::Value =
2054            serde_json::from_str(transactional::CREATE_ORGANIZATION_INVITATION_REQUEST_SCHEMA_JSON)
2055                .expect("transactional request schema");
2056        assert!(
2057            transactional_schema["properties"]["source"]["properties"]
2058                .get("plugin_id")
2059                .is_none()
2060        );
2061
2062        let receipt_request = delivery::ObserveReceiptRequest {
2063            attempt_id: "attempt".to_owned(),
2064            delivery_id: "delivery".to_owned(),
2065            digest: format!("sha256:{}", "a".repeat(64)),
2066            kind: delivery::ObserveReceiptRequestKind::Delivered,
2067            observation_id: "observation".to_owned(),
2068            observed_at: "2026-08-30T00:00:00Z".to_owned(),
2069            remote_id: "remote".to_owned(),
2070            run_id: "run".to_owned(),
2071        };
2072        let receipt = receipt_observation(
2073            "email-provider-blue".to_owned(),
2074            &receipt_request,
2075            ReceiptKind::Delivered,
2076            Utc::now(),
2077        );
2078        assert_eq!(receipt.source, "email-provider-blue");
2079        let delivery_schema: serde_json::Value =
2080            serde_json::from_str(delivery::OBSERVE_RECEIPT_REQUEST_SCHEMA_JSON)
2081                .expect("receipt request schema");
2082        assert!(delivery_schema["properties"].get("source").is_none());
2083    }
2084
2085    #[test]
2086    fn observation_authorities_are_disjoint_per_operation() {
2087        let plugin = unprepared_plugin();
2088        assert!(matches!(
2089            futures::executor::block_on(plugin.dispatch_due(
2090                caller_context("email-provider-blue"),
2091                delivery::DispatchDueRequest {},
2092            )),
2093            Err(PluginError::Domain(
2094                delivery::DispatchDueError::Unauthorized
2095            ))
2096        ));
2097
2098        let request = delivery::ObserveReceiptRequest {
2099            attempt_id: "attempt".to_owned(),
2100            delivery_id: "delivery".to_owned(),
2101            digest: format!("sha256:{}", "a".repeat(64)),
2102            kind: delivery::ObserveReceiptRequestKind::Delivered,
2103            observation_id: "observation".to_owned(),
2104            observed_at: "2026-08-30T00:00:00Z".to_owned(),
2105            remote_id: "remote".to_owned(),
2106            run_id: "run".to_owned(),
2107        };
2108        assert!(matches!(
2109            futures::executor::block_on(
2110                plugin.observe_receipt(caller_context("notification-worker-blue"), request,)
2111            ),
2112            Err(PluginError::Domain(
2113                delivery::ObserveReceiptError::Unauthorized
2114            ))
2115        ));
2116
2117        let lifecycle = transactional::ObserveInvitationLifecycleRequest {
2118            invitation_id: "invite".to_owned(),
2119            lifecycle: transactional::ObserveInvitationLifecycleRequestLifecycle::Revoked,
2120            observation_id: "observation".to_owned(),
2121            observed_at: "2026-08-30T00:00:00Z".to_owned(),
2122            organization_id: "organization".to_owned(),
2123        };
2124        assert!(matches!(
2125            futures::executor::block_on(plugin.observe_invitation_lifecycle(
2126                caller_context("notification-worker-blue"),
2127                lifecycle,
2128            )),
2129            Err(PluginError::Domain(
2130                transactional::ObserveInvitationLifecycleError::Unauthorized
2131            ))
2132        ));
2133    }
2134
2135    #[test]
2136    fn native_typed_requests_revalidate_every_bound_before_state_or_dependencies() {
2137        let now = parse_time("2026-08-30T00:00:00Z").expect("fixed validation time");
2138        for request in invalid_create_requests() {
2139            assert!(!valid_create_request(&request, now));
2140            assert!(matches!(
2141                futures::executor::block_on(
2142                    unprepared_plugin().create_organization_invitation(
2143                        caller_context("organization-blue"),
2144                        request,
2145                    )
2146                ),
2147                Err(PluginError::Domain(
2148                    transactional::CreateOrganizationInvitationError::InvalidIntent
2149                ))
2150            ));
2151        }
2152
2153        let mut invalid_access_request = generated_access_request();
2154        invalid_access_request.idempotency_key = "caller-selected".to_owned();
2155        assert!(!valid_access_request_notification_request(
2156            &invalid_access_request,
2157            now
2158        ));
2159        assert!(matches!(
2160            futures::executor::block_on(unprepared_plugin().create_access_request_notification(
2161                caller_context("organization-blue"),
2162                invalid_access_request,
2163            )),
2164            Err(PluginError::Domain(
2165                transactional::CreateAccessRequestNotificationError::InvalidIntent
2166            ))
2167        ));
2168
2169        let lifecycle = transactional::ObserveInvitationLifecycleRequest {
2170            invitation_id: "invite".to_owned(),
2171            lifecycle: transactional::ObserveInvitationLifecycleRequestLifecycle::Revoked,
2172            observation_id: "observation".to_owned(),
2173            observed_at: "2026-08-30T00:00:00Z".to_owned(),
2174            organization_id: "organization".to_owned(),
2175        };
2176        assert!(valid_lifecycle_request(&lifecycle));
2177        for request in [
2178            transactional::ObserveInvitationLifecycleRequest {
2179                observation_id: "x".repeat(241),
2180                ..lifecycle.clone()
2181            },
2182            transactional::ObserveInvitationLifecycleRequest {
2183                organization_id: "x".repeat(241),
2184                ..lifecycle.clone()
2185            },
2186            transactional::ObserveInvitationLifecycleRequest {
2187                invitation_id: "x".repeat(241),
2188                ..lifecycle.clone()
2189            },
2190            transactional::ObserveInvitationLifecycleRequest {
2191                observed_at: "not-a-time".to_owned(),
2192                ..lifecycle
2193            },
2194        ] {
2195            assert!(!valid_lifecycle_request(&request));
2196            assert!(matches!(
2197                futures::executor::block_on(
2198                    unprepared_plugin().observe_invitation_lifecycle(
2199                        caller_context("organization-blue"),
2200                        request,
2201                    )
2202                ),
2203                Err(PluginError::Domain(
2204                    transactional::ObserveInvitationLifecycleError::InvalidObservation
2205                ))
2206            ));
2207        }
2208
2209        let receipt = delivery::ObserveReceiptRequest {
2210            attempt_id: "attempt".to_owned(),
2211            delivery_id: "delivery".to_owned(),
2212            digest: format!("sha256:{}", "a".repeat(64)),
2213            kind: delivery::ObserveReceiptRequestKind::Delivered,
2214            observation_id: "observation".to_owned(),
2215            observed_at: "2026-08-30T00:00:00Z".to_owned(),
2216            remote_id: "remote".to_owned(),
2217            run_id: "run".to_owned(),
2218        };
2219        assert!(valid_receipt_request(&receipt));
2220        for request in [
2221            delivery::ObserveReceiptRequest {
2222                observation_id: "x".repeat(241),
2223                ..receipt.clone()
2224            },
2225            delivery::ObserveReceiptRequest {
2226                delivery_id: "x".repeat(161),
2227                ..receipt.clone()
2228            },
2229            delivery::ObserveReceiptRequest {
2230                attempt_id: "x".repeat(161),
2231                ..receipt.clone()
2232            },
2233            delivery::ObserveReceiptRequest {
2234                run_id: "x".repeat(161),
2235                ..receipt.clone()
2236            },
2237            delivery::ObserveReceiptRequest {
2238                observed_at: "not-a-time".to_owned(),
2239                ..receipt.clone()
2240            },
2241            delivery::ObserveReceiptRequest {
2242                remote_id: "x".repeat(321),
2243                ..receipt.clone()
2244            },
2245            delivery::ObserveReceiptRequest {
2246                digest: "sha256:not-a-digest".to_owned(),
2247                ..receipt
2248            },
2249        ] {
2250            assert!(!valid_receipt_request(&request));
2251            assert!(matches!(
2252                futures::executor::block_on(
2253                    unprepared_plugin()
2254                        .observe_receipt(caller_context("email-provider-blue"), request,)
2255                ),
2256                Err(PluginError::Domain(
2257                    delivery::ObserveReceiptError::InvalidReceipt
2258                ))
2259            ));
2260        }
2261
2262        for request in [
2263            admin::ListDeliveriesRequest {
2264                cursor: None,
2265                limit: Some(0),
2266                status: None,
2267            },
2268            admin::ListDeliveriesRequest {
2269                cursor: None,
2270                limit: Some(201),
2271                status: None,
2272            },
2273            admin::ListDeliveriesRequest {
2274                cursor: Some("x".repeat(161)),
2275                limit: None,
2276                status: None,
2277            },
2278            admin::ListDeliveriesRequest {
2279                cursor: Some(String::new()),
2280                limit: None,
2281                status: None,
2282            },
2283        ] {
2284            assert!(!valid_list_request(&request));
2285            assert!(matches!(
2286                futures::executor::block_on(
2287                    unprepared_plugin().list_deliveries(caller_context("console-blue"), request,)
2288                ),
2289                Err(PluginError::Domain(
2290                    admin::ListDeliveriesError::InvalidFilter
2291                ))
2292            ));
2293        }
2294
2295        for delivery_id in [String::new(), "x".repeat(161)] {
2296            assert!(!required_bounded(&delivery_id, 1, 160));
2297            assert!(matches!(
2298                futures::executor::block_on(unprepared_plugin().get_delivery(
2299                    caller_context("console-blue"),
2300                    admin::GetDeliveryRequest { delivery_id },
2301                )),
2302                Err(PluginError::Domain(admin::GetDeliveryError::InvalidRequest))
2303            ));
2304        }
2305
2306        for request in [
2307            admin::RetryDeliveryRequest {
2308                delivery_id: "x".repeat(161),
2309                idempotency_key: "retry".to_owned(),
2310                revision: 1,
2311            },
2312            admin::RetryDeliveryRequest {
2313                delivery_id: "delivery".to_owned(),
2314                idempotency_key: "x".repeat(241),
2315                revision: 1,
2316            },
2317            admin::RetryDeliveryRequest {
2318                delivery_id: "delivery".to_owned(),
2319                idempotency_key: "retry".to_owned(),
2320                revision: 0,
2321            },
2322            admin::RetryDeliveryRequest {
2323                delivery_id: "delivery".to_owned(),
2324                idempotency_key: "retry".to_owned(),
2325                revision: MAX_SAFE_WIRE_INTEGER + 1,
2326            },
2327        ] {
2328            assert!(!valid_retry_request(&request));
2329            assert!(matches!(
2330                futures::executor::block_on(
2331                    unprepared_plugin().retry_delivery(caller_context("console-blue"), request,)
2332                ),
2333                Err(PluginError::Domain(
2334                    admin::RetryDeliveryError::InvalidRequest
2335                ))
2336            ));
2337        }
2338
2339        let maximum_revision = admin::RetryDeliveryRequest {
2340            delivery_id: "delivery".to_owned(),
2341            idempotency_key: "retry".to_owned(),
2342            revision: MAX_SAFE_WIRE_INTEGER,
2343        };
2344        assert!(valid_retry_request(&maximum_revision));
2345        assert!(matches!(
2346            futures::executor::block_on(
2347                unprepared_plugin()
2348                    .retry_delivery(caller_context("console-blue"), maximum_revision,)
2349            ),
2350            Err(PluginError::Domain(
2351                admin::RetryDeliveryError::RetryNotAllowed
2352            ))
2353        ));
2354    }
2355
2356    #[test]
2357    fn native_admin_outputs_reject_unbounded_or_nonportable_storage_values() {
2358        let now = Utc::now();
2359        let summary = DeliverySummary {
2360            id: "delivery".to_owned(),
2361            recipient_mask: "a***@example.test".to_owned(),
2362            template_id: "organization-invitation".to_owned(),
2363            template_version: "v1".to_owned(),
2364            locale: "en".to_owned(),
2365            status: "queued".to_owned(),
2366            revision: 1,
2367            attempt_count: 0,
2368            max_attempts: 4,
2369            redacted_preview: "Invitation".to_owned(),
2370            content_digest: format!("sha256:{}", "a".repeat(64)),
2371            correlation_id: "story".to_owned(),
2372            next_attempt_at: Some(now),
2373            final_reason: None,
2374            created_at: now,
2375            updated_at: now,
2376        };
2377        assert!(admin_delivery(summary.clone()).is_ok());
2378        assert!(
2379            admin_delivery(DeliverySummary {
2380                revision: MAX_SAFE_WIRE_INTEGER + 1,
2381                ..summary.clone()
2382            })
2383            .is_err()
2384        );
2385        assert!(
2386            admin_delivery(DeliverySummary {
2387                id: "x".repeat(161),
2388                ..summary.clone()
2389            })
2390            .is_err()
2391        );
2392
2393        let attempt = AttemptRecord {
2394            id: "attempt".to_owned(),
2395            sequence: 1,
2396            function_run_id: "run".to_owned(),
2397            status: "dispatching".to_owned(),
2398            provider: None,
2399            remote_receipt_id: None,
2400            failure_code: None,
2401            failure_classification: None,
2402            started_at: now,
2403            completed_at: None,
2404        };
2405        assert!(admin_attempt(attempt.clone()).is_ok());
2406        assert!(
2407            admin_attempt(AttemptRecord {
2408                function_run_id: "x".repeat(161),
2409                ..attempt.clone()
2410            })
2411            .is_err()
2412        );
2413
2414        let detail = DeliveryDetail {
2415            delivery: summary,
2416            attempts: vec![attempt; ADMIN_ATTEMPT_LIMIT + 1],
2417            receipts: Vec::new(),
2418            retry_requests: Vec::new(),
2419        };
2420        assert!(admin_detail(detail).is_err());
2421
2422        let mapped = map_get_error(NotificationError::new(
2423            ErrorCode::EvidenceOverflow,
2424            "bounded evidence overflow fixture",
2425        ));
2426        assert!(matches!(
2427            mapped,
2428            PluginError::Domain(admin::GetDeliveryError::EvidenceOverflow)
2429        ));
2430
2431        assert!(
2432            admin_retry(RetryResult {
2433                delivery_id: "delivery".to_owned(),
2434                revision: MAX_SAFE_WIRE_INTEGER + 1,
2435                status: "retry_scheduled".to_owned(),
2436                scheduled_at: now,
2437                idempotent_replay: false,
2438            })
2439            .is_err()
2440        );
2441    }
2442
2443    #[test]
2444    fn native_email_response_validation_rejects_invalid_bounds_and_cross_fields() {
2445        let work = dispatch_work();
2446        let email_provider_instance = "email-provider-blue";
2447        let accepted = email::DispatchResponse {
2448            failure: None,
2449            observed_at: "2026-08-30T00:00:00Z".to_owned(),
2450            outcome: DispatchResponseOutcome::Accepted,
2451            provider: "email-provider-blue".to_owned(),
2452            remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2453                digest: format!("sha256:{}", "a".repeat(64)),
2454                remote_id: "remote".to_owned(),
2455                source: "email-provider-blue".to_owned(),
2456            }),
2457        };
2458        let accepted_observation =
2459            dispatch_observation(&work, email_provider_instance, accepted.clone())
2460                .expect("bound Provider response is valid");
2461        assert_eq!(accepted_observation.provider, email_provider_instance);
2462        assert_eq!(
2463            accepted_observation
2464                .remote_receipt
2465                .expect("accepted receipt")
2466                .source,
2467            email_provider_instance
2468        );
2469
2470        let temporary = email::DispatchResponse {
2471            failure: Some(email::DispatchResponseFailure {
2472                classification: "temporary_failure".to_owned(),
2473                code: "rate_limited".to_owned(),
2474                retry_after_ms: Some(1_000),
2475            }),
2476            observed_at: "2026-08-30T00:00:00Z".to_owned(),
2477            outcome: DispatchResponseOutcome::TemporaryFailure,
2478            provider: "email-provider-blue".to_owned(),
2479            remote_receipt: None,
2480        };
2481        assert!(dispatch_observation(&work, email_provider_instance, temporary.clone()).is_ok());
2482
2483        let invalid = [
2484            email::DispatchResponse {
2485                provider: String::new(),
2486                ..accepted.clone()
2487            },
2488            email::DispatchResponse {
2489                provider: "different-provider".to_owned(),
2490                ..accepted.clone()
2491            },
2492            email::DispatchResponse {
2493                observed_at: "not-a-time".to_owned(),
2494                ..accepted.clone()
2495            },
2496            email::DispatchResponse {
2497                observed_at: format!("2026-08-30T00:00:00.{}Z", "1".repeat(50)),
2498                ..accepted.clone()
2499            },
2500            email::DispatchResponse {
2501                failure: Some(email::DispatchResponseFailure {
2502                    classification: "permanent_failure".to_owned(),
2503                    code: "should-not-exist".to_owned(),
2504                    retry_after_ms: None,
2505                }),
2506                ..accepted.clone()
2507            },
2508            email::DispatchResponse {
2509                outcome: DispatchResponseOutcome::PermanentFailure,
2510                remote_receipt: None,
2511                failure: None,
2512                ..accepted.clone()
2513            },
2514            email::DispatchResponse {
2515                outcome: DispatchResponseOutcome::DeliveryUnknown,
2516                remote_receipt: None,
2517                failure: None,
2518                ..accepted.clone()
2519            },
2520            email::DispatchResponse {
2521                remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2522                    remote_id: String::new(),
2523                    ..accepted.remote_receipt.clone().expect("accepted receipt")
2524                }),
2525                ..accepted.clone()
2526            },
2527            email::DispatchResponse {
2528                remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2529                    remote_id: "x".repeat(321),
2530                    ..accepted.remote_receipt.clone().expect("accepted receipt")
2531                }),
2532                ..accepted.clone()
2533            },
2534            email::DispatchResponse {
2535                remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2536                    source: "different-provider".to_owned(),
2537                    ..accepted.remote_receipt.clone().expect("accepted receipt")
2538                }),
2539                ..accepted.clone()
2540            },
2541            email::DispatchResponse {
2542                remote_receipt: Some(email::DispatchResponseRemoteReceipt {
2543                    digest: "sha256:not-a-digest".to_owned(),
2544                    ..accepted.remote_receipt.clone().expect("accepted receipt")
2545                }),
2546                ..accepted.clone()
2547            },
2548            email::DispatchResponse {
2549                failure: Some(email::DispatchResponseFailure {
2550                    retry_after_ms: Some(-1),
2551                    ..temporary.failure.clone().expect("temporary failure")
2552                }),
2553                ..temporary.clone()
2554            },
2555            email::DispatchResponse {
2556                failure: Some(email::DispatchResponseFailure {
2557                    retry_after_ms: Some(86_400_001),
2558                    ..temporary.failure.clone().expect("temporary failure")
2559                }),
2560                ..temporary.clone()
2561            },
2562            email::DispatchResponse {
2563                failure: Some(email::DispatchResponseFailure {
2564                    code: String::new(),
2565                    ..temporary.failure.clone().expect("temporary failure")
2566                }),
2567                ..temporary.clone()
2568            },
2569            email::DispatchResponse {
2570                failure: Some(email::DispatchResponseFailure {
2571                    code: "x".repeat(161),
2572                    ..temporary.failure.clone().expect("temporary failure")
2573                }),
2574                ..temporary.clone()
2575            },
2576            email::DispatchResponse {
2577                failure: Some(email::DispatchResponseFailure {
2578                    classification: String::new(),
2579                    ..temporary.failure.clone().expect("temporary failure")
2580                }),
2581                ..temporary.clone()
2582            },
2583            email::DispatchResponse {
2584                failure: Some(email::DispatchResponseFailure {
2585                    classification: "x".repeat(161),
2586                    ..temporary.failure.clone().expect("temporary failure")
2587                }),
2588                ..temporary.clone()
2589            },
2590            email::DispatchResponse {
2591                failure: Some(email::DispatchResponseFailure {
2592                    classification: "permanent_failure".to_owned(),
2593                    ..temporary.failure.clone().expect("temporary failure")
2594                }),
2595                ..temporary.clone()
2596            },
2597            email::DispatchResponse {
2598                remote_receipt: accepted.remote_receipt,
2599                ..temporary
2600            },
2601        ];
2602        for response in invalid {
2603            assert!(matches!(
2604                dispatch_observation(&work, email_provider_instance, response),
2605                Err(RuntimeFailure::ProtocolViolation {
2606                    capability: email::CAPABILITY_ID
2607                })
2608            ));
2609        }
2610    }
2611
2612    #[test]
2613    fn linked_factory_rejects_invalid_configuration_before_startup() {
2614        let driver = DeterministicDriver::new();
2615        let error = driver
2616            .run(Kernel::start_native(
2617                invalid_actual_plan(),
2618                driver.clone(),
2619                NativePluginRegistry::new()
2620                    .with_linked_factories()
2621                    .with_factory(EmptyFactory(SECRETS_PACKAGE_ID))
2622                    .with_factory(EmptyFactory(EMAIL_PACKAGE_ID))
2623                    .with_factory(EmptyFactory(TEMPLATE_PACKAGE_ID)),
2624            ))
2625            .expect_err("invalid Notification authority must reject startup");
2626        assert!(matches!(error, RuntimeFailure::InvalidResolvedPlan { .. }));
2627    }
2628
2629    #[test]
2630    fn removing_notification_selection_removes_runtime_behavior() {
2631        let plan = AppComposition::new(
2632            vec![PluginInstancePlan::new("empty", EMPTY_PACKAGE_ID)],
2633            Vec::new(),
2634        )
2635        .resolve()
2636        .expect("App without Notification must resolve");
2637        let driver = DeterministicDriver::new();
2638        let app = driver
2639            .run(Kernel::start_native(
2640                plan,
2641                driver.clone(),
2642                NativePluginRegistry::new()
2643                    .with_linked_factories()
2644                    .with_factory(EmptyFactory(EMPTY_PACKAGE_ID)),
2645            ))
2646            .expect("unselected Notification Plugin must be inert");
2647        assert_eq!(
2648            driver.run(app.shutdown(Duration::from_secs(1))),
2649            ShutdownOutcome::Clean
2650        );
2651    }
2652
2653    #[test]
2654    fn configuration_rejects_ambient_or_ambiguous_authority() {
2655        let config = NotificationConfig::new(
2656            "notification/database",
2657            "notification/snapshot-key",
2658            vec!["organization".to_owned()],
2659            vec!["notification-worker".to_owned()],
2660            vec!["email-provider".to_owned()],
2661            vec!["notification-console-http".to_owned()],
2662        )
2663        .expect("valid config");
2664        assert_eq!(config.schema, "notification");
2665
2666        let mut duplicate_secret = config.clone();
2667        duplicate_secret.snapshot_key_secret = duplicate_secret.database_url_secret.clone();
2668        assert_eq!(
2669            duplicate_secret.validate(),
2670            Err(NotificationConfigError::InvalidSecretReference)
2671        );
2672
2673        let mut ambient = config;
2674        ambient.admin_callers.clear();
2675        assert_eq!(
2676            ambient.validate(),
2677            Err(NotificationConfigError::InvalidCallers)
2678        );
2679    }
2680
2681    #[test]
2682    fn removing_notification_needs_no_kernel_branch() {
2683        let remaining = lenso_app_plan::AppComposition::new(
2684            vec![lenso_app_plan::PluginInstancePlan::new(
2685                "organization",
2686                "test.organization",
2687            )],
2688            vec![],
2689        )
2690        .resolve()
2691        .expect("App without Notification should resolve");
2692        assert_eq!(remaining.plugin_instances().len(), 1);
2693        assert!(remaining.capability_bindings().is_empty());
2694    }
2695
2696    #[test]
2697    fn unknown_dispatch_is_terminal_and_redaction_safe() {
2698        let work = DispatchWork {
2699            claim: crate::runtime::DispatchClaim {
2700                delivery_id: "delivery".to_owned(),
2701                attempt_id: "attempt".to_owned(),
2702                run_id: "run".to_owned(),
2703            },
2704            request: DispatchRequest {
2705                delivery_id: "delivery".to_owned(),
2706                attempt_id: "attempt".to_owned(),
2707                run_id: "run".to_owned(),
2708                idempotency_key: "attempt".to_owned(),
2709                recipient: email::DispatchRequestRecipient {
2710                    address: "member@example.com".to_owned(),
2711                },
2712                message: email::DispatchRequestMessage {
2713                    template_id: "organization-invitation".to_owned(),
2714                    template_version: "v1".to_owned(),
2715                    locale: "en".to_owned(),
2716                    subject: "secret".to_owned(),
2717                    text: "secret".to_owned(),
2718                    html: "secret".to_owned(),
2719                    content_digest: format!("sha256:{}", "a".repeat(64)),
2720                },
2721                correlation_id: "correlation".to_owned(),
2722            },
2723        };
2724        let observation =
2725            unknown_dispatch(&work, "email-provider-blue", Utc::now(), "runtime_failure");
2726        assert_eq!(observation.outcome, DispatchOutcome::DeliveryUnknown);
2727        assert!(observation.failure.is_some());
2728        assert!(!format!("{observation:?}").contains("member@example.com"));
2729    }
2730
2731    #[test]
2732    fn stored_delivery_unknown_never_maps_to_a_retry_state() {
2733        assert_eq!(
2734            delivery_status("delivery_unknown").unwrap(),
2735            delivery::DispatchDueResponseStatus::DeliveryUnknown
2736        );
2737        assert!(crate::domain::can_transition(
2738            StoredDeliveryStatus::Attempting,
2739            StoredDeliveryStatus::DeliveryUnknown
2740        ));
2741        assert!(!crate::domain::can_transition(
2742            StoredDeliveryStatus::DeliveryUnknown,
2743            StoredDeliveryStatus::RetryScheduled
2744        ));
2745    }
2746
2747    fn run_generated_fixture(outcome: FixtureOutcome) -> InvocationResult {
2748        let observed = Rc::new(RefCell::new(None));
2749        let driver = DeterministicDriver::new();
2750        let app = driver
2751            .run(Kernel::start_native(
2752                generated_fixture_plan(),
2753                driver.clone(),
2754                NativePluginRegistry::new()
2755                    .with_factory(FixtureProviderFactory { outcome })
2756                    .with_factory(ConsumerFactory {
2757                        observed: observed.clone(),
2758                    }),
2759            ))
2760            .expect("generated Notification fixture must start");
2761        let outcome = observed
2762            .borrow_mut()
2763            .take()
2764            .expect("generated Client consumer must observe an invocation");
2765        assert_eq!(
2766            driver.run(app.shutdown(Duration::from_secs(1))),
2767            ShutdownOutcome::Clean
2768        );
2769        outcome
2770    }
2771
2772    fn generated_fixture_plan() -> ResolvedAppPlan {
2773        let consumer = PluginInstancePlan::new("consumer", CONSUMER_PACKAGE_ID).with_requirement(
2774            CapabilityRequirementPlan::one(
2775                transactional::CAPABILITY_ID,
2776                transactional::DESCRIPTOR_VERSION,
2777            ),
2778        );
2779        let provider = PluginInstancePlan::new("notification", FIXTURE_PROVIDER_PACKAGE_ID)
2780            .with_capability(CapabilityEndpointPlan::new(
2781                transactional::CAPABILITY_ID,
2782                transactional::DESCRIPTOR_VERSION,
2783                [
2784                    transactional::CREATE_ACCESS_REQUEST_NOTIFICATION_OPERATION,
2785                    transactional::CREATE_ORGANIZATION_INVITATION_OPERATION,
2786                    transactional::OBSERVE_INVITATION_LIFECYCLE_OPERATION,
2787                ],
2788            ));
2789        AppComposition::new(
2790            vec![consumer, provider],
2791            vec![CapabilityBinding::new(
2792                "consumer",
2793                transactional::CAPABILITY_ID,
2794                transactional::DESCRIPTOR_VERSION,
2795                "notification",
2796            )],
2797        )
2798        .resolve()
2799        .expect("generated Client/Provider Composition must resolve")
2800    }
2801
2802    fn invalid_actual_plan() -> ResolvedAppPlan {
2803        let invalid = NotificationConfig {
2804            schema: "notification".to_owned(),
2805            database_url_secret: "notification/database".to_owned(),
2806            snapshot_key_secret: "notification/snapshot-key".to_owned(),
2807            transactional_callers: Vec::new(),
2808            dispatch_callers: vec!["worker".to_owned()],
2809            receipt_callers: vec!["email-provider".to_owned()],
2810            admin_callers: vec!["admin".to_owned()],
2811        };
2812        let notification = PluginInstancePlan::new("notification", PACKAGE_ID)
2813            .with_configuration(serde_json::to_string(&invalid).expect("serialize invalid config"))
2814            .with_requirement(CapabilityRequirementPlan::one(
2815                lenso_capability_secrets::CAPABILITY_ID,
2816                lenso_capability_secrets::DESCRIPTOR_VERSION,
2817            ))
2818            .with_requirement(CapabilityRequirementPlan::one(
2819                email::CAPABILITY_ID,
2820                email::DESCRIPTOR_VERSION,
2821            ))
2822            .with_requirement(CapabilityRequirementPlan::one(
2823                notification_template::CAPABILITY_ID,
2824                notification_template::DESCRIPTOR_VERSION,
2825            ));
2826        let secrets = PluginInstancePlan::new("secrets", SECRETS_PACKAGE_ID).with_capability(
2827            CapabilityEndpointPlan::new(
2828                lenso_capability_secrets::CAPABILITY_ID,
2829                lenso_capability_secrets::DESCRIPTOR_VERSION,
2830                [lenso_capability_secrets::RESOLVE_OPERATION],
2831            ),
2832        );
2833        let email_provider = PluginInstancePlan::new("email", EMAIL_PACKAGE_ID).with_capability(
2834            CapabilityEndpointPlan::new(
2835                email::CAPABILITY_ID,
2836                email::DESCRIPTOR_VERSION,
2837                [email::DISPATCH_OPERATION],
2838            ),
2839        );
2840        let template_provider = PluginInstancePlan::new("templates", TEMPLATE_PACKAGE_ID)
2841            .with_capability(CapabilityEndpointPlan::new(
2842                notification_template::CAPABILITY_ID,
2843                notification_template::DESCRIPTOR_VERSION,
2844                [notification_template::RENDER_OPERATION],
2845            ));
2846        AppComposition::new(
2847            vec![notification, secrets, email_provider, template_provider],
2848            vec![
2849                CapabilityBinding::new(
2850                    "notification",
2851                    lenso_capability_secrets::CAPABILITY_ID,
2852                    lenso_capability_secrets::DESCRIPTOR_VERSION,
2853                    "secrets",
2854                ),
2855                CapabilityBinding::new(
2856                    "notification",
2857                    email::CAPABILITY_ID,
2858                    email::DESCRIPTOR_VERSION,
2859                    "email",
2860                ),
2861                CapabilityBinding::new(
2862                    "notification",
2863                    notification_template::CAPABILITY_ID,
2864                    notification_template::DESCRIPTOR_VERSION,
2865                    "templates",
2866                ),
2867            ],
2868        )
2869        .resolve()
2870        .expect("invalid configuration should pass structural Plan resolution")
2871    }
2872
2873    fn caller_context(caller: &str) -> InvocationContext {
2874        InvocationContext::new(1, None, CancellationToken::new())
2875            .with_caller_instance(caller.to_owned())
2876    }
2877
2878    fn unprepared_plugin() -> NotificationPlugin {
2879        NotificationPlugin {
2880            config: NotificationConfig::new(
2881                "notification/database",
2882                "notification/snapshot-key",
2883                vec!["organization-blue".to_owned()],
2884                vec!["notification-worker-blue".to_owned()],
2885                vec!["email-provider-blue".to_owned()],
2886                vec!["console-blue".to_owned()],
2887            )
2888            .expect("valid unprepared test Plugin"),
2889            secrets: Port::new(),
2890            email: Port::new(),
2891            templates: Port::new(),
2892            state: Rc::new(RefCell::new(None)),
2893        }
2894    }
2895
2896    fn invalid_create_requests() -> Vec<transactional::CreateOrganizationInvitationRequest> {
2897        let base = generated_request();
2898        vec![
2899            transactional::CreateOrganizationInvitationRequest {
2900                source: transactional::CreateOrganizationInvitationRequestSource {
2901                    entity_type: "x".repeat(161),
2902                    ..base.source.clone()
2903                },
2904                ..base.clone()
2905            },
2906            transactional::CreateOrganizationInvitationRequest {
2907                source: transactional::CreateOrganizationInvitationRequestSource {
2908                    entity_id: "x".repeat(241),
2909                    ..base.source.clone()
2910                },
2911                ..base.clone()
2912            },
2913            transactional::CreateOrganizationInvitationRequest {
2914                recipient: transactional::CreateOrganizationInvitationRequestRecipient {
2915                    address: format!("a@{}", "x".repeat(319)),
2916                    ..base.recipient.clone()
2917                },
2918                ..base.clone()
2919            },
2920            transactional::CreateOrganizationInvitationRequest {
2921                recipient: transactional::CreateOrganizationInvitationRequestRecipient {
2922                    display_name: Some("x".repeat(241)),
2923                    ..base.recipient.clone()
2924                },
2925                ..base.clone()
2926            },
2927            transactional::CreateOrganizationInvitationRequest {
2928                template: transactional::CreateOrganizationInvitationRequestTemplate {
2929                    organization_id: "x".repeat(241),
2930                    ..base.template.clone()
2931                },
2932                ..base.clone()
2933            },
2934            transactional::CreateOrganizationInvitationRequest {
2935                template: transactional::CreateOrganizationInvitationRequestTemplate {
2936                    organization_name: "x".repeat(241),
2937                    ..base.template.clone()
2938                },
2939                ..base.clone()
2940            },
2941            transactional::CreateOrganizationInvitationRequest {
2942                template: transactional::CreateOrganizationInvitationRequestTemplate {
2943                    invitation_id: "x".repeat(241),
2944                    ..base.template.clone()
2945                },
2946                ..base.clone()
2947            },
2948            transactional::CreateOrganizationInvitationRequest {
2949                template: transactional::CreateOrganizationInvitationRequestTemplate {
2950                    invitation_url: format!("https://{}", "x".repeat(4_089)),
2951                    ..base.template.clone()
2952                },
2953                ..base.clone()
2954            },
2955            transactional::CreateOrganizationInvitationRequest {
2956                template: transactional::CreateOrganizationInvitationRequestTemplate {
2957                    inviter_display_name: Some("x".repeat(241)),
2958                    ..base.template.clone()
2959                },
2960                ..base.clone()
2961            },
2962            transactional::CreateOrganizationInvitationRequest {
2963                template: transactional::CreateOrganizationInvitationRequestTemplate {
2964                    role_name: Some("x".repeat(161)),
2965                    ..base.template.clone()
2966                },
2967                ..base.clone()
2968            },
2969            transactional::CreateOrganizationInvitationRequest {
2970                template: transactional::CreateOrganizationInvitationRequestTemplate {
2971                    expires_at: "not-a-time".to_owned(),
2972                    ..base.template.clone()
2973                },
2974                ..base.clone()
2975            },
2976            transactional::CreateOrganizationInvitationRequest {
2977                idempotency_key: "x".repeat(241),
2978                ..base.clone()
2979            },
2980            transactional::CreateOrganizationInvitationRequest {
2981                correlation_id: "x".repeat(241),
2982                ..base.clone()
2983            },
2984            transactional::CreateOrganizationInvitationRequest {
2985                causation_id: Some("x".repeat(241)),
2986                ..base.clone()
2987            },
2988            transactional::CreateOrganizationInvitationRequest {
2989                requested_by: Some("x".repeat(241)),
2990                ..base
2991            },
2992        ]
2993    }
2994
2995    fn dispatch_work() -> DispatchWork {
2996        DispatchWork {
2997            claim: crate::runtime::DispatchClaim {
2998                delivery_id: "delivery".to_owned(),
2999                attempt_id: "attempt".to_owned(),
3000                run_id: "run".to_owned(),
3001            },
3002            request: DispatchRequest {
3003                delivery_id: "delivery".to_owned(),
3004                attempt_id: "attempt".to_owned(),
3005                run_id: "run".to_owned(),
3006                idempotency_key: "attempt".to_owned(),
3007                recipient: email::DispatchRequestRecipient {
3008                    address: "member@example.com".to_owned(),
3009                },
3010                message: email::DispatchRequestMessage {
3011                    template_id: "organization-invitation".to_owned(),
3012                    template_version: "v1".to_owned(),
3013                    locale: "en".to_owned(),
3014                    subject: "secret".to_owned(),
3015                    text: "secret".to_owned(),
3016                    html: "secret".to_owned(),
3017                    content_digest: format!("sha256:{}", "a".repeat(64)),
3018                },
3019                correlation_id: "correlation".to_owned(),
3020            },
3021        }
3022    }
3023
3024    fn generated_request() -> transactional::CreateOrganizationInvitationRequest {
3025        transactional::CreateOrganizationInvitationRequest {
3026            causation_id: Some("obs_invitation_fixture".to_owned()),
3027            correlation_id: "corr_notification_fixture".to_owned(),
3028            idempotency_key: "organization-invitation:fixture".to_owned(),
3029            recipient: transactional::CreateOrganizationInvitationRequestRecipient {
3030                address: "member@example.com".to_owned(),
3031                display_name: Some("Member".to_owned()),
3032                locale: transactional::CreateOrganizationInvitationRequestRecipientLocale::En,
3033            },
3034            requested_by: Some("usr_fixture".to_owned()),
3035            source: transactional::CreateOrganizationInvitationRequestSource {
3036                entity_id: "invite_fixture".to_owned(),
3037                entity_type: "organization_invitation".to_owned(),
3038            },
3039            template: transactional::CreateOrganizationInvitationRequestTemplate {
3040                expires_at: "2026-09-01T00:00:00Z".to_owned(),
3041                invitation_id: "invite_fixture".to_owned(),
3042                invitation_url: "https://example.test/invitations/secret".to_owned(),
3043                inviter_display_name: Some("Operator".to_owned()),
3044                organization_id: "org_fixture".to_owned(),
3045                organization_name: "Fixture Organization".to_owned(),
3046                role_name: Some("Member".to_owned()),
3047            },
3048        }
3049    }
3050
3051    fn generated_access_request() -> transactional::CreateAccessRequestNotificationRequest {
3052        transactional::CreateAccessRequestNotificationRequest {
3053            causation_id: Some("access_request_fixture:submitted".to_owned()),
3054            correlation_id: "corr_access_request_fixture".to_owned(),
3055            event: transactional::CreateAccessRequestNotificationRequestEvent::Submitted,
3056            expires_at: Some("2026-09-01T00:00:00Z".to_owned()),
3057            idempotency_key: "access-request:ar_fixture:submitted".to_owned(),
3058            organization_id: "org_fixture".to_owned(),
3059            recipient: transactional::CreateAccessRequestNotificationRequestRecipient {
3060                address: "requester@example.com".to_owned(),
3061                display_name: Some("Requester".to_owned()),
3062                locale: transactional::CreateAccessRequestNotificationRequestRecipientLocale::En,
3063            },
3064            request_id: "ar_fixture".to_owned(),
3065            requested_by: Some("subject_fixture".to_owned()),
3066            role: transactional::CreateAccessRequestNotificationRequestRole {
3067                display_name: Some("Member".to_owned()),
3068                role_id: "role_member".to_owned(),
3069            },
3070            scope: transactional::CreateAccessRequestNotificationRequestScope {
3071                display_name: Some("Fixture Organization".to_owned()),
3072                id: "org_fixture".to_owned(),
3073                kind: "organization".to_owned(),
3074            },
3075        }
3076    }
3077}