MMetiss AI|Docs
Technical Design Document

Orion — Event-Driven Notification System

Transactional email notifications for the Orion field service portal, triggered by GCP Pub/Sub push subscriptions from the Orion BFF — zero polling, publish and forget.

Status
Draft — for review
Owner
Engineering
Systems
Orion BFF · GCP Pub/Sub · FastAPI · Resend · Cloud SQL
Version
v0.1 — 2026-07-10
OrionEvent-DrivenEmail NotificationsGCP Pub/SubNew Microservice

01Purpose & Scope

Orion currently sends no outbound notifications. Job assignments go unannounced, invoice creation is silent, and technicians learn of new work only by checking the portal manually. This document specifies an event-driven notification system for the Orion field service portal whose primary channel is transactional email, built without any polling — every notification is triggered by a GCP Pub/Sub event pushed from the Orion BFF to a dedicated microservice.

In scope — New notification-service FastAPI microservice; three Pub/Sub topics published exclusively by the Orion BFF (platform.jobs, platform.invoices, platform.auth); push subscription wiring; email delivery via Resend; per-user notification preferences; delivery tracking (sent, bounced, opened).
Out of scope — Notifications from Widget, Vista, or Savings Insight (separate TDD when those services adopt Pub/Sub); in-app notification bell; SMS / push notifications; marketing or bulk email.

02Current State

Orion has no outbound notification capability. The table below documents the current situation for each domain within the portal.

DomainCurrent behaviourGap
Job assignmentNo outbound notification; technician must check the portalNo job-assigned or job-status-changed email sent
Job completion / cancellationStatus updates visible in portal; no notification to partner contactPartner unaware of field status changes without manual refresh
Invoice creationInvoice PDF visible in portal; no email to partner billing contactInvoice created / sent / paid events go unnotified
Auth events (Orion users)Password reset handled ad-hoc via a direct SMTP call; no invite email for new techniciansNo consistent template or delivery tracking; technician onboarding requires manual comms
GCP Pub/SubScaffolded in Orion; local EventLog driver active in production; Pub/Sub not wiredEvent bus exists but is unused — this design activates it

03Event Architecture

All Orion notification triggers flow through GCP Pub/Sub using push subscriptions. The Orion BFF publishes a lightweight JSON envelope and returns immediately — no knowledge of notification logic, no polling, no callbacks. Pub/Sub delivers the message to the notification service endpoint over HTTPS.

PUBLISHEROrion BFFEncoreTS · Cloud RunGCP PUB/SUBplatform.jobsplatform.invoicesplatform.authpush subscriptionHTTPSpushNOTIFICATION SERVICEFastAPI · Cloud RunPOST /events/pushPOST /events/webhookDATABASECloud SQL · PostgreSQLEMAIL PROVIDERResenddelivery webhooks

3.1 — Topic taxonomy

Three topics are in scope for this document — all published exclusively by the Orion BFF. Topics are created once in the metiss-dev GCP project and can gain additional subscribers later without changes to Orion or the notification service.

TopicPublished byEvent types carried
platform.jobsOrion BFFjob.assigned, job.status_changed, job.completed, job.cancelled
platform.invoicesOrion BFFinvoice.created, invoice.sent, invoice.paid, invoice.overdue
platform.authOrion BFFauth.invite_sent, auth.password_reset, auth.account_locked

3.2 — Event envelope schema

All events share the same envelope. Source-specific data goes in payload. The notification service acts on the envelope fields; it never assumes payload structure beyond what is documented in the event catalog (Section 07).

{
  "event_id":   "evt_01J3KQZ...",   // ULID — used as idempotency key
  "event_type": "job.assigned",      // dot-namespaced string
  "source":     "orion-bff",         // publishing service slug
  "created_at": "2026-07-10T14:23:00Z",
  "payload": {
    "job_id":    "uuid-...",         // resource ID — no PII in envelope
    "actor_id":  "uuid-...",         // user who triggered the event
    "recipient_ids": ["uuid-..."]    // user IDs who should be notified
  }
}
Thin envelope, no PII. Payloads carry resource IDs only. The notification service fetches recipient email addresses from the shared Cloud SQL users table at delivery time. This keeps events safe to log, audit, and replay without exposing personal data in the message bus.

3.3 — Why push, not pull

Pub/Sub supports two subscription modes. This design uses push exclusively.

ModeBehaviourWhy we avoid it
Pull (polling)Subscriber calls subscriber.pull() on a loop or schedule to fetch messagesPolling defeats the event-driven goal. A sleeping loop introduces latency proportional to the poll interval; a tight loop wastes CPU and creates cost.
Push this designPub/Sub calls the subscriber's HTTP endpoint the moment a message is availableZero latency gap; no background thread; Cloud Run scales to zero between bursts with no idle polling cost

04Notification Service

A new notification-service FastAPI microservice is introduced. It has a single responsibility: receive Pub/Sub push messages, decide who to notify and how, render the appropriate email template, and dispatch via Resend. It shares the existing Cloud SQL instance and deploys to Cloud Run alongside the other Metiss microservices.

4.1 — API surface

MethodPathPurposeAuth
POST/events/pushPub/Sub push subscription target — receives all platform eventsGCP OIDC bearer token (verified against Google public keys)
POST/events/webhookResend delivery webhooks — updates notification_log with delivery statusResend webhook signing secret (HMAC-SHA256)
GET/preferences/{user_id}Fetch a user's notification preference recordInternal service token (not exposed via API Gateway)
PUT/preferences/{user_id}Update preferences — called by Orion settings UI via the Orion BFFInternal service token
GET/unsubscribeOne-click unsubscribe link target — verifies HMAC token and opts user outHMAC-signed query params (no user session required)
GET/healthCloud Run health checkNone

4.2 — Request lifecycle (push endpoint)

1
Verify OIDC token
Pub/Sub attaches an Authorization: Bearer <oidc-token>header signed by Google. The service validates the token against Google's public key endpoint and confirms the audience matches the Cloud Run service URL. Rejects with HTTP 401 if invalid.
2
Decode and validate envelope
The Pub/Sub message body is base64-decoded and parsed as JSON. Pydantic validates the envelope schema. Malformed messages are acknowledged (HTTP 200) and written to an error log — not nacked, because a malformed message will never become valid and repeated retries would loop indefinitely.
3
Idempotency check
Query notification_log for a row where idempotency_key = event_id. If found, return HTTP 200 immediately — the notification was already sent; this is a Pub/Sub redelivery.
4
Load preferences and recipients
Resolve recipient_ids to email addresses via the users table. For each recipient, check user_notification_preference; skip users who have opted out of this event type.
5
Render and send
Select the Jinja2 template for event_type. Fetch any additional payload data needed for rendering (e.g. job details from Orion). Render subject + HTML body + plain-text fallback. Call the Resend API to send; capture the returned email_id.
6
Write notification_log and ACK
Insert a row into notification_log with status sent and the Resend email_id. Return HTTP 200 to acknowledge the Pub/Sub message. Pub/Sub will not redeliver.
Transient failure path — If the Resend API call times out or returns 5xx, the endpoint returns HTTP 500. Pub/Sub treats this as a nack and retries with exponential backoff (see Section 08). No notification_log row is written, so the idempotency check on the next delivery attempt will not short-circuit.

05Email Delivery

5.1 — Email provider: Resend

Resend is selected as the transactional email provider for the following reasons:

CriterionResendAlternative (SendGrid)
Python SDKOfficial SDK; async-nativeOfficial SDK; older API surface
Webhook eventsSvix-signed webhooks; delivery, bounce, open, clickEvent webhook with SendGrid signing key
Custom domain sendingDNS setup; DKIM + SPF auto-managedSame — manual DNS steps
PricingFree up to 3,000 emails/month; $20/month thereafterFree up to 100/day; complex pricing tiers
Sending domainRequires custom domain — use notifications@metiss.aiSame

5.2 — Template system

Email templates are Jinja2 files stored in notification-service/templates/. Each event type has two template files: an HTML file and a plain-text fallback. Template names follow the pattern {event_type}.html and {event_type}.txt (dots replaced by underscores in filenames).

templates/
  job_assigned.html          job_assigned.txt
  job_status_changed.html    job_status_changed.txt
  job_completed.html         job_completed.txt
  job_cancelled.html         job_cancelled.txt
  invoice_created.html       invoice_created.txt
  invoice_sent.html          invoice_sent.txt
  invoice_paid.html          invoice_paid.txt
  auth_invite_sent.html      auth_invite_sent.txt
  auth_password_reset.html   auth_password_reset.txt
  auth_account_locked.html   auth_account_locked.txt
Templates are shipped in the service image (not DB-managed) in v1. This keeps template changes as code reviews and avoids a DB-managed template UI for a feature that doesn't yet need that flexibility. A DB-backed template system can be added later without changing the service contract.

5.3 — Unsubscribe

Every notification email includes an RFC 8058 List-Unsubscribeheader and a visible “Unsubscribe” link in the footer. The link target is:

GET /unsubscribe
  ?user_id=<uuid>
  &event_type=<type>
  &nonce=<ulid>
  &sig=<hmac-sha256-hex>

The HMAC is computed server-side at send time using a secret key. Verification on click confirms the link was issued by the service and has not been tampered with. No user session is required — a single click opts the user out without a login wall.

One-click unsubscribe scope — Clicking unsubscribe opts the user out of that specific event_typeonly, not all notifications. A “manage all preferences” deep-link in the email footer directs users to the Vista Portal settings page for full control.

06Data Model

Three tables are added to the shared Cloud SQL instance (same database as Orion). No new database or Cloud SQL instance is required.

notification_log

Immutable append-only record of every notification attempt. The idempotency_key column is indexed and used to deduplicate Pub/Sub redeliveries.

ColumnTypeNotes
idUUID PKULID-formatted UUID
idempotency_keyTEXT UNIQUE NOT NULLPub/Sub message ID (base64) — deduplication index
event_typeTEXT NOT NULLe.g. job.assigned
event_idTEXT NOT NULLevent envelope event_id field
recipient_user_idUUID NOT NULLFK → users.id
recipient_emailTEXT NOT NULLSnapshot of email at send time
channelTEXT NOT NULLAlways 'email' in v1
statusTEXT NOT NULLsent | delivered | bounced | complained | failed
provider_email_idTEXTResend email ID — used to correlate delivery webhooks
sent_atTIMESTAMPTZ NOT NULLWhen the Resend API call succeeded
delivered_atTIMESTAMPTZPopulated by Resend webhook
opened_atTIMESTAMPTZPopulated by Resend webhook (requires tracking pixel)
error_messageTEXTPopulated on failure or bounce reason
created_atTIMESTAMPTZ NOT NULL DEFAULT now()

user_notification_preference

Per-user, per-event-type opt-in/out. Default is opted in; a row is only written when a user changes a preference from the default.

ColumnTypeNotes
idUUID PK
user_idUUID NOT NULLFK → users.id
event_typeTEXT NOT NULLe.g. job.assigned
email_enabledBOOLEAN NOT NULL DEFAULT truefalse = opted out
updated_atTIMESTAMPTZ NOT NULL DEFAULT now()
UNIQUE (user_id, event_type)

notification_template (v2 placeholder)

Reserved for a future DB-managed template system. Not populated or queried in v1 — the service uses filesystem Jinja2 templates. Schema is created now to avoid a migration later.

ColumnTypeNotes
idUUID PK
event_typeTEXT UNIQUE NOT NULL
subjectTEXT NOT NULLJinja2 subject line
html_bodyTEXT NOT NULLJinja2 HTML body
text_bodyTEXT NOT NULLJinja2 plain-text body
activeBOOLEAN NOT NULL DEFAULT falsefalse = use filesystem template
updated_atTIMESTAMPTZ NOT NULL DEFAULT now()

07Event Catalog

All events in scope for v1 — published exclusively by the Orion BFF. Events marked planned require Orion to add the Pub/Sub publish call before they go live.

Event typeTriggerDefault recipientsStatus
job.assignedJob assigned to a technicianAssigned technicianPending
job.status_changedJob transitions to In Progress, On Hold, or DelayedPartner contact on the jobPending
job.completedJob marked completePartner contact + assigned technicianPending
job.cancelledJob cancelledAssigned technician + partner contactPending
invoice.createdNew invoice generated in OrionPartner billing contactPending
invoice.sentInvoice marked as sent to customerPartner contactPending
invoice.paidInvoice payment recordedPartner billing contactPending
invoice.overdueInvoice crosses due date unpaidPartner billing contactDeferred — v2
auth.invite_sentNew Orion user (technician or partner admin) invitedInvitee email addressReplaces ad-hoc
auth.password_resetPassword reset requested by an Orion userRequesting user emailReplaces ad-hoc
auth.account_lockedAccount locked after repeated failed login attemptsAccount ownerPending
invoice.overdue is the only event that requires a scheduled trigger rather than a reactive one. A Cloud Scheduler job publishing to platform.invoices daily is the cleanest approach — it keeps the Orion BFF as the sole publisher and avoids pulling query logic into the notification service. Deferred to v2.

08Delivery Guarantees

8.1 — Idempotency

Pub/Sub guarantees at-least-once delivery, not exactly-once. The notification service implements its own exactly-once guarantee at the application layer:

  1. Each Pub/Sub message carries a unique messageId generated by GCP. This is stored as idempotency_key in notification_log.
  2. Before sending, the service queries notification_log by idempotency_key. If a row exists, the endpoint returns HTTP 200 immediately.
  3. The notification_log insert and the Resend API call are not in the same database transaction — Resend has no transactional rollback. Instead, the log row is written after a successful Resend response, keeping the window of unlogged delivery narrow (see failure path below).
Narrow duplicate window — If the service crashes between the Resend call and the notification_loginsert, the email was sent but the row was not written. On Pub/Sub redelivery, the idempotency check misses and a second email is dispatched. This window is short (milliseconds) and acceptable for v1. Mitigation: use Resend's idempotency_key header; Resend will return the original email_id on a duplicate send attempt instead of sending again.

8.2 — Retry and backoff

ConditionHTTP responsePub/Sub behaviour
Success — email sent200 OKMessage acknowledged; never redelivered
Permanent error — unknown event type, malformed envelope200 OK + error loggedMessage acknowledged to avoid infinite retry loop; error written to notification_log with status failed
Transient error — Resend API timeout, DB connection failure500 Internal Server ErrorMessage nacked; Pub/Sub retries with exponential backoff (10s → 600s), up to the subscription ack deadline
OIDC token invalid401 UnauthorizedMessage nacked; Pub/Sub retries — indicates a configuration error requiring operator action

8.3 — Dead-letter topic

The push subscription is configured with a dead-letter topic: platform.notifications.dlq. After 7 delivery attempts (Pub/Sub default), unacknowledged messages are forwarded there. An alert fires on any DLQ message via Cloud Monitoring. DLQ messages are inspected manually and replayed if the root cause is fixed.

09Security

9.1 — Push endpoint authentication

The POST /events/push endpoint is not exposed via the GCP API Gateway (which handles partner/public traffic). It is deployed as a private Cloud Run service accessible only by the Pub/Sub service account. The OIDC token check provides a second layer of verification.

# Pub/Sub push subscription configuration
audience: https://notification-service-<hash>-uc.a.run.app
service_account: pubsub-push-sa@metiss-dev.iam.gserviceaccount.com

9.2 — Resend webhook authentication

Resend signs webhooks using Svix. The POST /events/webhook endpoint verifies the svix-id, svix-timestamp, and svix-signature headers using the Resend webhook signing secret stored in Secret Manager. Requests failing verification are rejected with HTTP 403.

9.3 — Unsubscribe token security

Unsubscribe links are HMAC-SHA256 signed. The service computes:

sig = HMAC-SHA256(
  key  = UNSUBSCRIBE_SECRET,      # from Secret Manager
  data = f"{user_id}:{event_type}:{nonce}"
)

On click, the service recomputes and compares in constant time using hmac.compare_digest. Nonces are ULIDs; a nonce is marked used in user_notification_preference on first verification to prevent replay.

9.4 — PII handling

Event payloads carry only UUIDs — no names, emails, or addresses. The notification service resolves PII from Cloud SQL at dispatch time and does not log it beyond therecipient_email snapshot in notification_log (necessary for delivery tracking). Pub/Sub message logs (Cloud Logging) therefore contain no PII.

10Test Plan

Test caseExpected resultStatus
Publish job.assigned event → verify email receivedTechnician email in inbox within 5 secondsPending
Publish same event twice (same event_id) → verify single emailSecond delivery idempotency check fires; no duplicate sentPending
User opts out via unsubscribe link → publish event → verify no emailpreference row written; notification skipped; notification_log row with status skippedPending
Resend returns 5xx → verify retryEndpoint returns 500; Pub/Sub redelivers; second attempt succeedsPending
Push with invalid OIDC token → verify rejectionEndpoint returns 401; message stays in Pub/Sub backlogPending
Resend webhook fires (delivered) → verify notification_log updatednotification_log.status = delivered; delivered_at populatedPending
Malformed event payload → verify graceful handlingEndpoint returns 200 (ack); error row in notification_log; no crashPending
7 consecutive 500s → verify DLQ receiptMessage appears in platform.notifications.dlq; alert firesPending

11Open Questions

QuestionDefault / recommendationDecision needed from
Wire Pub/Sub in Orion production (currently EventLog driver) — do this before or in parallel with building the notification service?In parallel — the notification service has no value until Orion topics receive messagesEngineering lead
Sending domain: notifications@metiss.ai or a subdomain like mail.metiss.ai?notifications@metiss.ai for simplicity; subdomain isolates deliverability reputation from the main domainEngineering + Marketing
Should Orion notification preferences be accessible from within the Orion portal in v1?Yes — at minimum a toggle per event group (jobs, invoices) in user settingsProduct
Email open tracking: enable Resend tracking pixel or opt out for privacy?Opt out by default; enable only if product requires delivery funnel metricsProduct + Legal
invoice.overdue (v2): Cloud Scheduler → Pub/Sub, or a cron inside the Orion BFF?Cloud Scheduler → Pub/Sub — keeps Orion BFF as the single publisher and avoids a background cron in the BFFEngineering lead
Technical Design Document · v0.1 · 2026-07-10Owner: Engineering