Skip to content

Notifications & email — the plan

Status: N0–N2, N3 (most), N4 (part), N6 and N6.1's Portal half are BUILT; the header below is kept for the shape of the plan, not as a status line — read the per-gate headings. The notify primitive itself shipped with foundation 1A.18 and works; what is missing is almost everything that would make a clinic's patients and staff hear from it.

This document is the single plan for notification and email work across the platform. It supersedes the scattered notes in platform-completion.md → Patients receive NO email and the per-feature "notify the patient" lines in apps/docs/features/, which are design intent, not shipped behaviour — several of them read as if the send already happens.

1. What exists at HEAD

services/api/internal/core/notify/ is an in-process Go primitive: callers name a category, never a channel; Send renders at enqueue and stores the rendered subject + body on the notifications row; a polling dispatcher claims notification_deliveries FOR UPDATE SKIP LOCKED and ships stored bytes. Design rationale lives in the package's own doc.go and in foundation.md → 1A.18.

Eight categories are registered. All are email-only and all are transactional:

CategoryProducer at HEADScopeReaches
owner_welcomeorganization.Service.Createplatformorg owner
break_glass_openedbreakglass.Serviceplatformclinic admins
webhook_subscription_pausedwebhooks/dispatcher/notifier.goplatformclinic admins
content_render_failedexercises/rendernotify/notifier.goplatformcontent owner
email_change_confirmserver.EmailChangeMailerplatformanyone, incl. patients
email_change_noticeserver.EmailChangeMailerplatformanyone, incl. patients
form_session_linkserver.FormSessionLinkstenantpatient
member_invitezero callersplatforminvited staff

Two corrections to what the docs and the standing notes say:

  • "Patients receive no email, ever" is now too strong. Three categories reach a patient — the form-session link and both halves of the email-change flow. What remains true, and is the launch blocker, is that nothing clinical or appointment-related sends anything: grepping appointments, protocols, patientsubscriptions, appointmentdocuments and video for notify. returns zero hits.
  • member_invite is not merely unused — it is a category with templates in both locales and no call site. Staff invitations go out through Clerk's invitation provider (invites.Service.CreateStaffauth.InvitationProvider), entirely bypassing notify. So the platform has two unrelated ways to mail a human, and only one of them is recorded in notifications.

2. Eleven gaps, each verified against code

These are the reason the plan starts where it does rather than with an appointment-confirmation template.

G1 — Classification is dead code, and it is the GDPR axis.ClassOperational and ClassMarketing are declared and documented at length in notify/category.go, and consulted by nothing. Service.Send never reads the classification; notification_preferences is never SELECTed by any code path; the consents ledger is never touched by notify. Every shipped category being transactional hides this completely. The first operational category — an appointment reminder — makes it load-bearing, because an operational send that ignores an opt-out is a compliance defect, not a missing feature.

G2 — notification_preferences has no writer, and no organisation scope. The table, its RLS self-CRUD policies and its channel CHECK all shipped in 000010. No endpoint writes it and no UI exposes it. Worse for the model chosen in §3: the primary key is (recipient_principal_id, category, channel)there is no organization_id. A patient treated at two clinics has one row for appointment_reminder, so opting out at Clinic A silences Clinic B. That is a migration, and it must land before the first operational category.

G3 — there is no per-org notification policy at all. A clinic cannot choose its reminder lead times, cannot turn a category off, cannot decide that it does not send program-update notices. No table, no endpoint, no settings surface.

G4 — organizations.email_from_name is stored, validated, API-exposed, and read by nobody. organization/branding.go validates it to 120 chars and the OpenAPI spec carries it; grepping the whole service for EmailFromName outside the organization domain returns nothing. The SES adapter takes from_address from the resolved provider row and that is the end of it. Custom mail-from is promised by the white-labeling commitment in CLAUDE.md; every patient-facing category is tenant-scope, so this stops being cosmetic the moment N3 ships.

G5 — no bounce or complaint handling, and no suppression list.infra/modules/email-ses/main.tf is a scaffold with zero resources — its header specifies the SNS topics and event destinations, and they were deferred for want of a consumer. The env files ship only a configuration set. So there is no signature-verified inbound webhook, no notification_suppression table, and no precheck in EmailChannel: a hard bounce is invisible and the platform would keep mailing a dead address forever. With one shared account, one clinic's stale list damages deliverability for every tenant, and AWS acts on the account rather than on the clinic.

G6 — notify.At() can schedule a send, and nothing can cancel one.notifications.scheduled_at exists and the dispatcher honours it, but there is no way to say "this appointment was cancelled, drop its pending deliveries". A T-24h reminder enqueued at booking time will fire at a patient whose appointment was cancelled a week earlier.

G7 — no attachments. notify/email/email.go calls SES SendEmail with Simple content: subject, text, HTML. No ICS calendar invite, no PDF report, no raw-MIME path. Booking confirmations without an .ics are a visible downgrade from what leo does today.

G8 — the events bus is too thin to drive this. 22 event names are registered platform-wide, almost all organisation- or form-related. appointments, protocols, patientsubscriptions, appointmentdocuments and video publish nothing. Since §3 settles on the bus as the trigger seam, adding publishers to those five domains is a prerequisite of N3–N5 — and the events package's own convention forbids retrofitting them later ("a feature that did not publish at creation cannot be silently picked up later without revisiting every callsite").

G9 — rendered patient PII is readable by any org member with organizations.view_directory. The notifications_select_org_member RLS policy grants SELECT on the whole row — including subject and body_text, which after N3 contain a patient's name, appointment time and clinician. That policy was written when every category was platform-scope admin plumbing. It needs narrowing before the first tenant-scope clinical category, not after.

G10 — at-least-once with no claimed-row sweep. The claim transaction commits before channel.Send, so a worker crash between the SES call and markSent re-delivers. Accepted at four platform categories per year; at reminder volume it means patients receiving the same reminder twice, and a claimed row that no worker owns is never reclaimed.

G11 — retention is designed, not built. platform-completion.md specifies 18 months for notifications / notification_deliveries and 30 days for notification_idempotency_keys. api-partition-roll only creates partitions going forward; nothing drops or archives. These tables hold rendered PII, so unbounded growth is a GDPR exposure and not merely a disk-space one.

3. Four decisions, settled with the owner 2026-08-25

DecisionRulingConsequence
Channels in scopeEmail only. SMS/WhatsApp/push stay the ChannelAdapter extension point they already are.No provider selection, no phone verification, no SMS consent purpose in this plan. notification_preferences.channel already CHECKs sms/whatsapp/push, so adding one later needs no migration.
Trigger seamThe events bus. Domains publish after commit; a notify subscriber maps event → category.G8 becomes a prerequisite: publishers land in the five silent domains before their notifications do. The same publishers are what F7 automations and outbound webhooks need, so the cost is shared, not spent twice.
Who decides a send happensPer-org policy + per-patient opt-out. The clinic chooses which categories it sends and with what lead times; the patient can opt out of operational ones.Needs both a new per-org policy table and the G2 migration that adds organisation scope to notification_preferences. Faithful to "clinic is data controller"; the patient opt-out is what makes the operational classification lawful.
Sender identityPer-org verified identity, platform fallback — one SES account, verification an UPGRADE.Built in N1.1. Mail sends from the clinic where verified, from the platform identity with the clinic's display name otherwise. Finally makes G4's dead column load-bearing. It does not isolate reputation — see N1.4.
Deliverability flaggingMeasure sends and bounces per clinic; warn, then auto-pause.N1.4. Verified identities share account reputation, so per-clinic measurement plus a tenant-scope pause is the only thing standing between one clinic's dirty list and AWS pausing every clinic's mail. Platform and the clinic both see the numbers.

4. The inventory — everywhere a notification is needed

4.1 Patient-facing (tenant-scope, clinic → patient)

#NotificationTriggerClassNotes
P1Booking confirmation + ICSappointments create, after committransactionalRecipient is appointments.contact_emailpatient_profiles has no email column and a guest booking has no humans row. Needs G7.
P2Reminder (T-24h, T-2h)scheduled sweepoperationalLead times are per-org policy. Needs G1, G2, G3, G6.
P3Rescheduledappointment time changetransactionalMust revoke P2's pending sends and re-enqueue.
P4Cancelled by cliniccancelled_by_clinictransactionalMust revoke P2.
P5Appointment confirmedupcoming → confirmedoperationalThe docs already claim this exists. It does not.
P6Video join linkvideo.OpenRoom / T-15m before an online appointmenttransactionalLink must not outlive the room.
P7Form requestedBUILTform_session_linktransactionalThe one working patient path.
P8Form overduesweep over unfilled form_triggersoperational
P9Re-consent requiredversion supersession → the 412 gatetransactionalToday the gate is silent: the patient discovers it only by logging in and being blocked.
P10Program prescribedprotocols.Create / .Approvetransactional
P11Program updatedApplyMyProgramUpdates, ResumeAfterContentEditoperational
P12Protocol paused / resumed / endedPause, Resume, EndoperationalClinical pause only — a content_edit pause is invisible to the patient by design.
P13Adherence nudgesweep over missed sessionsoperationalThe first category a patient will plausibly opt out of.
P14Subscription expiring / expiredpatient-subscriptions-expiry-sweep (cron exists, sends nothing)transactional
P15Report publishedappointmentdocuments.PublishtransactionalPDF as attachment or portal link — see §6. Needs G7 if attached.
P16Patient invite / claim logininvites.Service.CreatePatienttransactionalDelivered by Clerk today, bypassing notify. Endpoint exists with zero callers in any app — no invite-a-patient button.
P17Account closure confirmedaccountclosure.Closetransactional
P18GDPR export readygdprexport.BuildForSubjecttransactionalArt. 15 response; the link must be single-use and short-lived.
P19Access offer fulfilledaccessoffers.ProvisionOffertransactional

4.2 Staff-facing

#NotificationTriggerClassNotes
S1Staff inviteinvites.Service.CreateStafftransactionalCategory + both locales exist with zero callers; Clerk delivers instead. Decide: retire the category, or route Clerk's invite through notify so it lands in notifications.
S2New booking on my calendarappointment createdoperationalPer-specialist opt-out is the point of G2.
S3Cancelled by patientcancelled_by_patient / cancelled_lateoperational
S4No-show recordedappointment-noshow-sweepoperationalCron exists; sends nothing.
S5Patient completed / signed a formform.completed, form.signedoperationalThe events already exist — this is the cheapest staff notification to build.
S6Patient waiting in video roomvideo.WithPresenceNotifieroperationalPresence hook exists and notifies nobody. In-app is the right channel; email is too slow to matter.
S7Daily schedule digestscheduledoperational
S8Patient subscription lapsingexpiry sweepoperationalFront-desk copy of P14.
S9Ownership transfer initiated / accepted / declined / cancelled4 events already registeredtransactionalCurrently a silent state change on the org's ownership.
S10Break-glass openedBUILTtransactional
S11Webhook subscription pausedBUILTtransactional
S12Content render failedBUILTtransactional

4.3 Platform / owner-facing

#NotificationTriggerStatus
X1Owner welcomeorganization.Service.Create✅ BUILT
X2Email change confirm / noticeserver.EmailChangeMailer✅ BUILT
X3Quota threshold reached1C.7 meteringnot built — the first thing a clinic hitting emails_sent_per_month should hear about, and ironically it would be blocked by the same quota
X4Org billing eventsorgbillingnot built; F12 is out of scope, so this is a stub until then

Totals: 19 patient-facing triggers of which 1 is built; 12 staff-facing of which 3 are built; 4 platform of which 2 are built.

5. Build order

Each stage is a gate on the next. N0–N2 build nothing a user sees, and skipping them is what turns this into thirty categories resting on a compliance lie.

N0 — make classification real (closes G1, G9) — BUILT 2026-08-25

  • [x] Service.Send reads Catalog()[cat].Classification and branches: transactional sends; operational consults notification_preferences. An unrecognised classification is refused, not sent.
  • [x] marketing fails closed rather than shipping an unexercised ledger read. The consents ledger keys on patient_profile_id, not principals, so honouring a marketing consent means resolving recipient → profile → purpose row, and answering what a staff recipient or a bare address even means in that model. No marketing category is registered and none is planned through N5, so writing that resolution now would ship a second consent gate nothing exercises — the exact defect G1 records. The first genuine marketing category implements it against a real consumer; TestCatalog_NoMarketingCategoryUntilLedgerReadExists says so at build time.
  • [x] A suppressed send records why: a terminal suppressed delivery status plus notification_deliveries.suppressed_reason, tied together in both directions by chk_notification_deliveries_suppressed_shape. A row is written rather than omitted because "we chose not to send" and "we never got round to it" are the same silence in a table that only records attempts, and only one of them is compliance.
  • [x] notification_preferences.organization_id — pulled forward from N1, because N0 is the code that first reads the table and writing that read twice is waste. Uniqueness moved from a PRIMARY KEY to two partial unique indexes: a PK cannot hold NULL, platform-scope rows need a NULL org, and a plain UNIQUE would let one recipient accumulate unlimited platform rows for a (category, channel) since NULL never equals NULL.
  • [x] Dropped notifications_select_org_member outright rather than re-gating it. Nothing reads these tables on the app pool, so minting a permission for an endpoint that does not exist would be inventing N6's answer. N6 adds the org arm back with a designed permission and a projection that excludes the rendered body.
  • [x] Tests: branch logic in notify/gate_test.go (nil repository on purpose — each of those arms must decide before touching storage); scope matching, index behaviour and the suppressed-shape CHECK in rlstest/notify_preferences_test.go.

Folded into 000010 in place, per the licensed convention, with the catch-up script at infra/scripts/000010-notification-classification-gate.sql for any database at version ≥ 10 that will not be rebuilt (local only today). Verified the required way: a from-scratch build of the edited chain diffed against the patched catalog — zero drift across 3,990 objects, the only differences being partition FKs partition-roll had created on the long-lived local database.

What N0 could NOT test, and N3 owns: the wiring from Send through to a stored suppressed delivery row. No operational category is registered yet, and registering a fake one in the production Catalog() to make a test pass would be its own defect. N3's first reminder category carries that test.

N1 — identity, deliverability, preferences, policy (closes G2, G3, G4, G5)

ONE SES ACCOUNT, ALWAYS — the one that already exists. Settled 2026-08-25. A separate AWS account per clinic would put each new clinic back in the SES sandbox, which needs a human support ticket to exit, plus its own quota increase, its own DKIM setup and cross-account IAM. Clinic onboarding would mean filing AWS tickets and waiting days. The account is sandbox-exited account-wide (2026-05-13) and every clinic inherits that; separate accounts throw the asset away. It also contradicts single-account Path A and the "dedicated infrastructure per tenant is permanently out of scope" rule.

"Per clinic" is therefore expressed as verified identities and configuration sets inside the one account, in four layers — and it is worth being precise about which of them actually isolate anything.

N1.1 Sender identity — an upgrade, never a requirement

Two tiers, because most small clinics will not manage DNS records:

TierFrom lineWhat it costs the clinic
DefaultClinica X <no-reply@restartix.pro>nothing — BUILT 2026-08-25, and it is where organizations.email_from_name finally became load-bearing after being stored, validated, API-exposed and read by nobody since it shipped
VerifiedClinica X <no-reply@clinicax.ro>three DKIM CNAMEs, the same DNS conversation custom domains already require
  • [x] The default tier is done. Every clinic's mail is now signed with its own name — branding.email_from_name when set, otherwise the organisation's name, so a clinic gets a branded From without touching a setting. Platform-scope sends stay unsigned: those are RestartiX talking to somebody about their account, and signing them with a clinic's name would be a lie.

    **The header goes through `net/mail`, not string concatenation.** The
    launch market is Romania, so a display name with diacritics —
    "Clinica Sănătate" — is the common path, and a non-ASCII From header
    must be RFC 2047 encoded or it reaches the recipient as mojibake and
    some providers refuse the message outright. Names also carry commas and
    full stops ("Dr. Pop, Cabinet Medical") that need RFC 5322 quoting.
    Hand-rolling gets one of the two wrong, invisibly, until the first
    clinic with a `ș` signs up. A failed name lookup degrades to the bare
    address rather than failing the send.
    
  • [ ] Verification flow: CreateEmailIdentity + Easy DKIM in the platform account → show the clinic its three CNAMEs → poll until verified → flip an explicit state column on the org.

    **Blocked on AWS access, not on design.** Provisioning an identity, a
    per-clinic configuration set and an SES tenant are all mutations
    against the live account, and none of them can be exercised from a
    laptop. Writing the flow now would ship a few hundred lines that no
    test and no run has ever executed — the same defect this plan records
    against the marketing gate in N0. It lands once staging's Terraform is
    applied and there is somewhere to run it.
    
  • [ ] Optional custom MAIL FROM subdomain (send.clinicax.ro, MX + SPF TXT) for SPF alignment and bounce attribution.

  • [ ] Per-org config lands in the platform_service_providers override row that already exists — the table has a nullable organization_id, a partial unique index per (capability, org), and the documented resolution ORDER BY organization_id NULLS LAST LIMIT 1. config JSONB carries from_address, from_name, configuration_set; credentials stay empty, meaning "use the platform's IAM chain".

This stays Cat A (curated provider, platform-credentialed). The clinic never hands us SES credentials — we send with ours, from their verified domain. It is not Cat B and does not belong in organization_integrations.

⛔ The verification state must be stored, never inferred. If a clinic's domain publishes DMARC p=reject and we send unaligned from no-reply@clinicax.ro without having verified it, every message is rejected outright. The display-name fallback is safe precisely because the From domain stays restartix.pro.

N1.2 A configuration set per clinic — from onboarding, not from verification

Today there is exactly one per environment (restartix-production). A clinic on the default identity still needs its own set, because that is what makes its bounces attributable at all.

  • [ ] One aws_sesv2_configuration_set per org, created at onboarding.
  • [ ] Event destination → SNS (bounce, complaint, delivery).
  • [ ] Message tags on every send: org_id and delivery_id. Not optional — see the trap in N1.3.

N1.3 Feedback ingest and suppression — BUILT 2026-08-25, Terraform unapplied

Nothing exists today: infra/modules/email-ses/main.tf is a scaffold with zero resources, and the env files ship only the configuration set. So a hard bounce is invisible and the platform would keep mailing a dead address forever.

  • [x] SNS topics + event destinations, in Terraform, unapplied — two topics rather than one, so an operator can mute bounces or complaints independently. Delivery events are deliberately not published: one per message sent is pure volume.
  • [x] Signature-verified inbound webhook (Cat D) at /webhooks/awsses, following the P52 five-step flow the check-inbound-webhooks guard enforces.
  • [x] notification_suppression + the precheck.

Two things about the verification are worth knowing before touching it.

The certificate-URL host check is the whole scheme. Without it an attacker points SigningCertURL at a certificate they control, signs the payload with the matching key, and every other check passes.

And the signature alone proves nothing about who sent the message. Any AWS customer can have SNS sign a payload with a genuine Amazon certificate, so a valid signature establishes only that some AWS account sent it. Only the topic ARN establishes that ours did — and this endpoint writes the suppression list, so without the allow-list anyone with an AWS account and the URL can silence any patient's mail. NOTIFY_SES_FEEDBACK_TOPIC_ARNS is not optional in production and the bootstrap warns when it is empty.

The precheck runs in the dispatcher, not in the channel adapter, and not at enqueue. Not at enqueue, because a scheduled send may be queued days before it goes out and the address can be suppressed in between. Not in the adapter, because that sits downstream of the metering wrap — a suppressed send reaching MeteredChannel would Reserve against the clinic's emails_sent_per_month and then never send, charging a clinic for a message the platform refused to deliver.

It fails open, which is the opposite of N0's posture and deliberately so: a suppression protects reputation from a mailbox already known to be dead, and a database blip is not that. Refusing a patient's appointment confirmation because a lookup timed out is worse than one more bounce.

Suppression scope differs by reason, and the distinction is not cosmetic:

ReasonScopeWhy
Hard bounce (Permanent)platform-widethe mailbox does not exist for anybody; re-sending from another clinic burns the same account reputation
Complaintorg-scoped"I do not want this clinic's mail" is a statement about one relationship, not about the address
Manualeither, explicitlysupport action

Transient bounces do not suppress — a full mailbox recovers — but repeated ones escalate to a hard suppression.

provider_message_id is unindexed and the table is partitioned. A webhook that looks a delivery up by SES message id scans every monthly partition. That is why N1.2 tags each send with delivery_id and its partition date: the feedback handler addresses the row directly, pruned to one partition.

N1.4 Per-clinic deliverability measurement and flagging — BUILT 2026-08-25 (server side; UI outstanding)

The reason this matters: verified identities do NOT isolate reputation. Bounces and complaints from one clinic count against the account's rates, and AWS reviews and pauses the account, not the identity. Per-clinic identity buys branding and attribution; it buys no blast-radius protection. Measurement plus a pause is what buys that.

Sends are already measured. MeteredChannel records the emails_sent meter per org for tenant-scope sends through the 1C.7 stack. What is missing is the other half.

  • [x] Denormalised organization_id onto notification_deliveries with an index on (organization_id, created_at) — done with the N1.3 schema.
  • [x] Feedback columns on the delivery row.
  • [x] Health bands computed live, no rollup table.
  • [x] notification_sending_pauses — at most one live pause per org, the triggering measurements snapshotted on the row, soft lift.
  • [x] The dispatcher gate, ahead of the suppression check and ahead of the metering wrap.
  • [x] notification-deliverability-sweep, hourly, in both environments' Terraform.
  • [x] deliverability_warning + sending_paused categories, en and ro.
  • [ ] Console surface — every org ranked by risk. DeliverabilityRanked is built and paginated; nothing renders it yet.
  • [ ] Clinic surface — the clinic's own counts, rates and band. DeliverabilityFor is built; nothing renders it yet.
  • [ ] The lift action needs a Console endpoint. LiftSendingPause exists and demands a principal; no route calls it, so a pause currently cannot be lifted except by SQL. That is the gap to close before the sweep is allowed to pause anything on production.

Why the snapshot columns exist. The rates are derived live over a rolling window, so by the time anyone reads a pause row the window has moved and the numbers that caused it are gone. A clinic told "your sending is stopped" is owed the figures.

Why the sweep never lifts. A pause that expired once the window aged out would resume mailing exactly the list that caused the problem, on a schedule, with nobody having looked at it. LiftSendingPause requires a principal and the table's CHECK enforces it.

Why hourly. The window is 30 days so the numbers barely move hour to hour, but the gap between a clinic crossing the line and the platform noticing is a gap in which the shared account keeps taking damage. The sweep cannot flap, because it only ever pauses.

Do not compute the rate from the meter. Meters are immutable monthly counters answering "what does this clinic owe"; the rate is a rolling window answering "is this clinic dangerous right now", and a bounce arrives after the meter was recorded. Numerator and denominator both come from notification_deliveries. The two never merge.

Thresholds sit below AWS's, so a clinic is caught before it endangers the others. AWS reviews at ~5% bounce / 0.1% complaint and can pause all sending.

BandBounceComplaintWhat happens
healthy< 2%< 0.05%nothing
warning2–5%0.05–0.1%alert platform and the clinic; nothing stops
danger> 5%> 0.1%tenant-scope sends for that org are refused until cleared
  • [ ] Two-stage response (settled 2026-08-25): warn, then auto-pause. The pause gate lives in ScopedChannel's tenant branch, so platform-scope mail keeps flowing — which is what lets the "your email is paused" notice reach the clinic admin at all.
  • [ ] Unpause is an explicit, audited action after the clinic cleans its list — never a timer.
  • [ ] Two new platform-scope categories: deliverability_warning and sending_paused, to clinic admins.
  • [ ] Surfaces (settled 2026-08-25 — platform and the clinic both): Console lists every org ranked by risk; the Clinic app shows that clinic its own counts, rates and band. The clinic is the only party who can actually clean a dirty list, so hiding the numbers from them makes the flag unactionable.

⛔ A low-volume clinic has a meaningless rate. One bounce in three sends is 33% and means nothing. The bands must not fire below a minimum-volume floor, or every clinic's first week reads as a crisis.

⛔ A suppressed send must not consume quota. Reserve happens before the SES call; a send the suppression precheck refuses never reaches SES, so the precheck belongs before Reserve or the reservation must be refunded.

N1.5 Preferences and policy — PARTLY BUILT 2026-08-25

  • [x] Migration: add organisation scope to notification_preferencesdone in N0, which is where the first reader landed.

  • [x] Per-org notification policyorganization_notification_policy, sparse, plus the gate in Send, GET/PUT /v1/organizations/{id}/notification-policy, and audit on the write.

  • [x] Preferences read/write on the service — ListPreferences / SetPreference, refusing anything that is not operational.

  • [ ] Reminder lead timesmoved to N3, which is the stage that knows what a reminder's shape is. A lead-time column added now would be a guess at a schema whose only consumer does not exist.

  • [x] Clinic policy UI — BUILT 2026-08-25. Organization → Emails: every notification the clinic sends, an on/off switch for each, reminder timing, and the rendered text of each message.

    **The preview is the point of the screen.** A list of switches labelled
    `appointment_reminder` tells a clinic nothing about what its patients
    read, and the wording is the part a clinic actually has an opinion
    about. Rendered server-side with representative values, in the clinic's
    own locale — previewing reminders in English while patients receive
    Romanian means signing off on text nobody gets.
    
    `configured` is surfaced separately from `enabled`: a page where every
    toggle looks deliberately set is a page a clinic cannot reason about.
    Transactional categories are labelled "cannot be declined by the
    patient", because the difference between *the clinic* switching
    something off and *a patient* declining it is the whole model.
    
    Reminder timing is **presets, not a number field**. A free integer
    invites 0 (fires at the appointment) and 500 (not a reminder); the
    server refuses both, and the picker stops a clinic finding that out the
    hard way.
    
  • [x] Patient (Portal) preference UIBUILT 2026-09-04 with N6.1; /notifications/settings. It was pointless while every category was transactional; appointment_reminder and video_consultation_starting are operational, so there are real toggles to render.

  • [ ] Clinic policy UI — the server side is complete and unrendered; the screen is small and lands with N3, when there is more than one category on it.

Why the policy outranks classification, including transactional. That looks wrong until you ask who is deciding. Classification answers a GDPR question about the recipient: may we send this to somebody who did not ask for it. The policy answers a product question the controller owns: does this clinic's service include emailing form links at all. A clinic doing its paperwork on a tablet at reception may legitimately never want one sent, and "transactional" is not the platform's grounds for overriding the controller.

Bounded to tenant scope, and that boundary is the point. A clinic that could switch off a platform-scope category could switch off its own warnings — the break-glass alert, or the notice that its sending has been paused. Those are refused by the service and are not even listed as options. Tests pin both.

⛔ Why there is no preference UI, and why building one would be wrong today. Only operational categories can be opted out of, and every category registered today is transactional — the recipient's relationship with the platform depends on each of them arriving. So the opt-outable list is empty, and a settings screen would either render nothing or, far worse, draw switches for the transactional ones. A patient offered a toggle that silently does nothing has been lied to about control they do not have. ListPreferences returns an empty list on purpose and a test asserts it; the UI lands with N3's first reminder, which is the first thing anyone can decline.

N1.6 The account as it actually is (verified against AWS 2026-08-25)

Read this before designing anything against SES; two things widely assumed here were wrong.

SES state is PER-REGION, and the account has two. us-east-1 is an untouched default sitting in the sandbox (200/day, 1/sec) and is irrelevant. eu-central-1 is the real one: production access granted, HEALTHY, 211,500/day at 36/sec. Quotas, verified identities, the suppression list and reputation are all per-region and entirely separate between the two — so a screenshot showing "sandbox" is not a contradiction, it is the wrong region.

The account is effectively idle: 11 sends in the last 30 days, 0 bounces, 0 complaints. The registered use case reads "500,000 emails per month for a 50,000 address contact base", which is the narrative from the production-access request, and every suppression entry dates from 2022. It describes a marketing operation that ran years ago, not one running now. Do not plan around a live marketing blast sharing this account — an earlier revision of this document did, and it was wrong.

What is actually there, in eu-central-1:

Verified domainsrestartix.ro, restartix.pro, restartix.online ✅ · restartix.eu FAILED · dr-fix.us disabled
Configuration setsrestartix-production, restartix-staging, and bm_decad65b…not ours, some other tool
Suppression list4,864 addresses, account-level, BOUNCE + COMPLAINT both enabled, all 2022-era
Dedicated IPsnone
Event destinationsnone — nothing captures bounces (this is G5)
TenantsAPI present, zero tenants

SES tenant management IS available here. The full API is live — create-tenant, create-tenant-resource-association, get-tenant, list-tenant-resources — and AWS describes tenants as "logical containers… along with reputation metrics and sending status… to isolate and manage email sending for different customers." That is the ISV case exactly.

Decisions taken 2026-08-25:

  • One account, no platform-vs-marketing separation. Reasonable precisely because nothing else is using the account. Three separation levels remain available if that changes: tenants (free, instant), a second region (separate quota/identities/suppression, needs a production-access request), a second AWS account (total, needs sandbox exit and production access).
  • Each clinic becomes an SES tenant — AWS-enforced per-tenant reputation and sending status, rather than the app layer approximating it.
  • Keep N1.4's app-layer warn-then-pause anyway. Tenants demonstrably give per-tenant metrics and sending status; whether AWS's automated enforcement acts per-tenant or still falls back to the account is NOT established here and should be read in current AWS docs before anyone relies on it. Belt and braces costs nothing extra.
  • The 4,864 old suppressions get cross-checked against the migration set, and genuine patients cleared — a 2022 marketing bounce is not evidence a mailbox is dead in 2026.

⛔ Residual, accepted knowingly. Leaving the account-level list authoritative means clinical mail stays governed by marketing history permanently, not just for the migrating 20k: a patient who signs up next year whose address bounced in 2022 is silently unreachable, and the migration cross-check cannot catch them because they were never in the migration set. The fix is configuration-set-level suppression, which overrides the account list per config set — build the hook, leave it disabled, so enabling it later is a flag rather than a rework.

Not worth it yet: dedicated IP pools (~$25/month per IP plus warming). A cold or low-volume dedicated IP has worse deliverability than the shared pool, so this is for a high-volume clinic later, not for launch.

N2 — scheduling and revocation (closes G6, G7, G10) — BUILT 2026-08-25

  • [x] Revocation. notify.About(entityType, id) binds a scheduled send to what it is about; RevokeScheduled calls off everything still queued. Idempotent and safe to call speculatively — a cancellation handler should not first ask whether anything was scheduled.

    **A flat side table, not columns on `notifications`.** That table is
    partitioned on `created_at`, and looking a send up by an appointment id
    has no time bound — the query would scan every month the platform has
    ever run. `notification_schedules` is flat, carries each parent's
    partition key, and the revoking UPDATE therefore addresses exactly the
    right partitions. `notification_idempotency_keys` is flat for the same
    reason and is the precedent.
    
    **`revoked` is its own terminal status, not `suppressed`.** Suppressed
    means the platform *decided* not to send — an opt-out, a dead mailbox, a
    clinic's setting. Revoked means the thing the message was about stopped
    existing. Merged, "how many reminders did we cancel" and "how many
    patients opted out" become the same number.
    
    Only `pending` rows move. A sent delivery cannot be recalled and a
    claimed one belongs to a worker mid-send; reaching into either would
    record a message as revoked that the recipient is reading.
    
  • [x] Raw-MIME path, so a booking confirmation can carry its .ics. Simple content stays the default — assembling MIME by hand is more surface to get wrong, and most messages need none of it.

    The `multipart/alternative` is nested *inside* `multipart/mixed`.
    Flattened, a client is told the plain text, the HTML and the calendar
    file are three interchangeable renderings of one thing and picks one —
    most often showing the `.ics` instead of the message. Header values are
    stripped of CR/LF: the rendered subject comes from a template fed by
    producer data, so a newline there could inject a Bcc into a message the
    platform believes it composed.
    
    **Attachments are capped at 256 KiB and stored on the row.** That is
    right for a calendar invite and wrong for a clinical PDF: the send log is
    partitioned and retained 18 months, so attaching reports would grow it by
    the size of every document the platform has ever produced. The cap
    enforces the boundary rather than merely documenting it. Whether a report
    is attached or linked remains §6's open question.
    
  • [x] Claimed-row sweep, on every dispatcher tick rather than as a cron — the fix belongs next to the thing that creates the problem, and a separate scheduled job would be one more thing to notice had stopped. attempts is deliberately not reset: the row may well have reached SES before the worker died, so treating a reclaim as a fresh start would let one stuck delivery retry forever.

N3 — appointments (P1–P6, S2–S4) — the launch-blocking set

P1 booking confirmation is BUILT (2026-08-25). The first clinical message the platform has ever sent a patient: before it, somebody who booked — at the desk, through the Portal, or publicly — received nothing at all.

  • [x] P1 confirmation + .ics. appointment_booked, tenant-scope, ro/en. Covers all three booking paths, because public booking and Portal self-booking both delegate to Service.Book.

  • [x] The internal/shared/ical generator. Deliberately small — one VEVENT, UTC instants only, no parsing and no recurrence. Anything more belongs in a real library added as a SOUP dependency.

  • [x] P4 cancellation revokes everything still queued about the appointment. A reminder that survives its own cancellation reaches a patient days later telling them to attend something that is not happening — worse than no reminder, because they act on it.

  • [x] The end-to-end gate test N0 could not write. appointment_booked is the first tenant-scope category a clinic can switch off, so it is the first proof that the gate reaches from a clinic's setting all the way to a stored, unsent delivery.

  • [x] P2 reminders + the sweep + per-org lead times.appointment_reminder is operational — the appointment exists and the patient already knows, so a reminder is a service they may decline, unlike the confirmation which is proof their booking worked.

    **A sweep, not a queue-at-booking.** Enqueueing when the appointment is
    made freezes the clinic's lead times at that moment: a clinic moving
    from one day's notice to two would see the change apply only to later
    bookings, and every appointment made before reminders existed would
    never get one. The sweep re-derives from current settings every run.
    
    **Exactly-once is the idempotency key, `(appointment, lead)`** — so
    overlapping windows, a restart mid-run and a manual re-run all converge
    on one reminder. That is what lets the look-ahead exceed the interval,
    which is what stops a late run dropping reminders silently.
    
    Lead times live in the policy row's `config` (`{"lead_hours":[24,2]}`).
    Default is **one** reminder a day ahead: two is a defensible clinic
    policy, several is a nuisance, and a clinic that never opens the setting
    must not have been mailing its patients twice a day.
    

⛔ A defect this stage found and fixed: the opt-out was unreachable for every email category. notification_preferences keys on a principal, but the queue-time guard requires every email-routed category to be addressed with ToAddress — no adapter resolves a principal to an address, so producers must do it at the call site. The two rules together meant an email send never carried a principal, a preference lookup keyed on one never matched, and every recorded opt-out was silently ignored. The gate now resolves through humans.email, so anyone with an account is honoured. N0's comment framed this as a guest-booking edge case; it was in fact the common path, and nothing caught it because no operational category existed to exercise it.

  • [x] P3 reschedule notice, shipped with its revoke — as promised, the two together. Revoking alone drops the patient's reminder and replaces it with nothing; notifying alone leaves a reminder queued for a time that no longer exists. The next sweep re-queues against the new instant.

    The notice carries a **replacement** invite: same UID as the
    confirmation, higher `SEQUENCE`, method `REQUEST`. A calendar client
    ignores an update that does not outrank what it holds, so a reschedule
    that forgets to bump the sequence is accepted by the mail client and
    discarded by the calendar — the patient reads the new time and their
    calendar keeps the old one. `CANCEL`-then-`REQUEST` would also work but
    removes the entry and adds one back, visibly, with a window in which
    they have nothing.
    
    The sequence is derived from `updated_at - created_at` in seconds
    rather than stored: monotonic, increases on every change, starts at
    zero, stays small. A stored counter would be a column, a migration and
    a second thing to forget to bump.
    
    Idempotency is keyed on the **revision**, not the appointment — an
    appointment moved twice owes the patient two notices.
    
  • [ ] P4 cancellation NOTICE (the revoke is done). Deliberately not built: a cancellation is nearly always made with the patient on the phone or at the desk, so the clinic has already told them. A notice belongs to the paths where nobody spoke to them — an automatic cancellation, or one the clinic makes without contact — and the domain does not yet draw that distinction.

  • [x] P6 — built, but NOT as this plan described it. The plan said "video join link". The video domain forbids exactly that, in its own words: video.Service.IssueJoin states that a separate invitation or link "would be a second way into a consultation — and the whole design rests on there being exactly one."

    That is also the right security answer independently. **A join URL with
    a token in it is a bearer credential to a clinical video call**: it sits
    in an inbox indefinitely, it forwards, it is scanned by mail providers,
    and it admits whoever holds it. So `video_consultation_starting` carries
    a **pointer to the Portal** and nothing else — the patient signs in and
    the Portal issues the join. A test asserts neither template contains a
    token, a `/join/` path or a JWT.
    
    Separate from the ordinary reminder because timing and content differ: a
    reminder goes out the day before and says when to turn up; this goes out
    **thirty minutes** before and says how to get in. Its own switch, too —
    a clinic may want one and not the other, and a shared toggle would let
    one decision answer for two.
    
  • [x] P4 cancellation notice, S3 patient-cancelled, S4 no-show — built on one rule: tell the party who did NOT act.

    The clinic cancels → the patient is told, with a `CANCEL` calendar
    object reusing the confirmation's UID so the entry is *removed* from
    their calendar rather than left there being wrong. The patient cancels →
    the front desk is told, because a cancellation made at eleven at night
    is invisible until somebody opens the diary and a freed slot is worth
    something only if they find out in time to use it. A sweep marks a
    no-show → the front desk is told, because otherwise nobody learned it at
    all and the same-day call that would have rebooked the patient is gone.
    
    Neither party hears about their own action. Echoing it back is noise,
    and worse than noise for a patient: "your appointment has been
    cancelled", moments after they cancelled it, reasonably reads as a
    second appointment they did not know about.
    
    The staff notices are **one category with a `kind`**, not three. The
    content is the same question — which appointment, when, what happened —
    and three near-identical template pairs would drift. Recipients are
    gated on `appointments.manage`: front-desk authority, which is exactly
    who acts on a freed slot.
    
  • [ ] P5 "appointment confirmed"deliberately not built. confirmed is a staff act, not a patient reply: the clinic has checked consents are signed and the video room is set up. It is an internal readiness step, and a patient who already has a booking confirmation would be receiving a second message about a workflow that is none of their business.

  • [ ] S2 "new booking on my calendar"deliberately not built. A public booking already lands in booked, which the model defines as the callback queue ("somebody has to ring them"). That is a visible workflow the clinic already works through; an email about it adds a second channel for the same fact.

  • [x] Event publishers in the appointments domain (also unblocks F7). Five events — booked, rescheduled, cancelled, completed, noshow — one payload shape, published after the service returns per the events convention.

    **Derived from the before/after pair, not from which handler ran.**
    `mutate` is the shared path for transition, cancel and reschedule, so a
    per-handler call would have to be added to each and forgotten on the
    fourth. What actually changed is the honest source of the answer. The
    internal `booked → upcoming → confirmed` progressions emit nothing —
    they would be noise on every appointment, several times each — and
    there is no `appointment.updated`, which would make a subscriber diff
    the payload to find out whether it cared.
    
    **The payload boundary needed twelve new `webhook_egress` entries**, and
    what is deliberately absent is the point: `contact_name` / `_email` /
    `_phone` never travel (a subscriber needing to reach a patient calls the
    API with its own credentials), nor does `cancellation_reason` (free text
    where a diagnosis or a bereavement lands), `booking_client_id` (a
    cross-booking correlator by construction), or `protocol_id` /
    `session_id` (clinical linkage the payload has no need of).
    
    **⛔ Nothing enforces that boundary at the dispatcher.** The Cat C path
    does not call `classification.AllowedFor` — the pre-existing framework
    gap recorded in data-classification.md. Until it does, the registry
    entries are a declaration and `TestPayloadFor_CarriesNoContactDetailsOrFreeText`
    is the actual boundary.
    

Three decisions inside the confirmation worth not re-litigating.

The send rides the request transaction. notify.Send joins whatever transaction the request opened, so the notification row is written atomically with the appointment: a booking that rolls back leaves no confirmation, and one that commits cannot fail to have queued one. Sending after the commit instead — which an earlier note in this plan recommended — opens a window in which the appointment exists and the patient will never hear about it.

Times render in the CLINIC's timezone, not the reader's. A patient reading "11:00" needs the time the clinic will expect them; rendered in the reader's own zone, a confirmation says something different to somebody who books while abroad.

A booking with no contact address sends nothing, and that is not an error. A walk-in booked at the desk for somebody with no address on file was told in person.

Trap, carried from the earlier finding: send after the transaction commits. A Send inside withAdminTx fires on a rolled-back booking.

N4 — clinical (P8–P15, S5, S8) — PARTLY BUILT 2026-08-25

One package, internal/core/clinicalnotify, because every clinical message needs the same awkward thing first: a patient's email address.

⛔ THE RULE, worth stating once. patient_profiles has no email column. A patient's address lives on their humans row and human_id is nullable — a patient the clinic registered at the desk, or a child on a family plan, has no login and therefore no address the platform knows. Those patients are unreachable by email, and that is the answer rather than a gap to work around. Every send degrades to silence, logged at debug: a clinic whose patients are mostly desk-registered would otherwise fill its logs with a fact about how it operates.

The resolver joins patients at the org, always — a profile spans clinics, a patients row does not, so a clinic cannot resolve a patient it does not hold. Tested.

  • [x] P10 programme prescribed. Transactional: a programme the patient was never told about is a programme they do not do, and this is the moment the Portal becomes worth opening for somebody who may never have opened it. Carries the programme's NAME and nothing else clinical — what it contains is behind their login, not in an inbox that forwards.

  • [x] P14 subscription expiring. Operational, and sent before the lapse. The existing sweep makes the stored status honest after access has stopped, which is useful to the clinic and useless to the patient; the warning goes first, seven days out, so they can still decide to renew. Keyed on (subscription, end date) so a renewal that moves the date earns a fresh warning and a daily sweep sends one rather than seven.

  • [x] P15 document published — category and templates. The document is NOT attached: a clinical report in an inbox is a report in a mailbox the clinic does not control, forwarded and cached and indexed. Signing in is also the only way the platform can record that they read it.

  • [x] P9 re-consent required — category and templates. Until now the 412 gate was silent and a patient discovered they had been stopped only by signing in and being blocked.

  • [x] P15 producerappointmentdocuments.Publish. Only on the FIRST publish: a withdraw-and-re-release is the clinic correcting itself, and telling the patient twice about one document invites them to wonder which is real. The resolver gained a by-appointment lookup, because a report is generated against a consultation rather than against a person.

  • [ ] ⛔ P9 producer — NOT built, and the reason is worth reading before anyone builds it.

    Publishing a new version of an agreement means **every patient who
    accepted the old one owes a re-consent**. That is not one message, it is
    the platform's first mass send — a clinic with two thousand patients
    emails two thousand people the moment somebody saves a privacy notice.
    
    **And it collides with N1.4.** The bounce rate is computed over sends in
    a rolling window. A clinic mailing its entire list at once surfaces
    every stale address it has ever accumulated, in one window — which is
    exactly the shape that trips the danger band. **A clinic that publishes
    a new agreement could pause its own patient email**, and the pause is
    deliberately not self-lifting.
    
    So the producer needs a rate-limited sweep, and the pacing is a real
    decision rather than an implementation detail: drip slowly and a blocked
    patient waits days to learn why the Portal stopped working; send fast
    and the clinic risks silencing itself. That is the owner's call, not one
    to make quietly inside a commit.
    
    The category and both locales are ready for whichever answer wins.
    
  • [ ] P8 form overdue, P11–P13 programme updated / paused / adherence nudge, S5 form completed, S8 subscription lapsing (staff copy).

  • [ ] Event publishers in protocols, patientsubscriptions, appointmentdocuments.

  • [ ] Form-overdue and adherence sweeps.

  • [ ] Re-consent notice wired to version supersession.

N5 — staff and identity (S1, S6, S7, S9, P16, P17, P18, P19)

  • [ ] Settle S1: retire member_invite or route Clerk's invite through notify.
  • [ ] Invite-a-patient button in the Clinic app, wired to the endpoint that has existed with zero callers since it shipped.
  • [ ] Presence, digest, ownership-transfer, closure, export, access-offer notices.

N6 — the notification log — BUILT 2026-08-25 (bell still open)

  • [x] GET /v1/organizations/{id}/notification-log plus a separate body read, and both surfaces over them: the clinic-wide list under Organization → Emails, and the same query scoped to one person on the patient's Activity tab. One component, because "what did we send this patient" and "what did we send anyone" differ only in scope.
  • [x] notifications.view_org — its own permission, seeded to admin and customer_support. The foundation RLS policy granted exactly this on organizations.view_directory, and N0 deleted it saying the arm would return "with a designed permission". This is that permission: the rows carry the RENDERED message — a patient's name, their appointment time, their clinician — and reading the staff directory is not the same question as reading patients' mail. A specialist does not hold it.
  • [x] notifications.patient_profile_id, denormalised for the same reason audit_log has one: the recipient columns cannot answer "what did we send this person". An address changes, a guest booking carries no principal, and a join through humans.email finds only today's address.
  • [x] The list carries no message bodies. The subject identifies a message; the full text is a separate, deliberate read, so a routine glance does not put every patient's correspondence on one screen. A test asserts the list payload contains none.
  • [x] The patient filter takes a patients id, not a profile id. A profile spans clinics; a patients row does not, so a clinic cannot name another clinic's patient even by guessing. Same rule F15 states for profile predicates: join patients at the searching org.
  • [ ] The bellnotification_deliveries.read_at has existed since the table shipped with nothing reading it. Still nothing does. The log answers the question a clinic actually asks; the bell is a different feature and can wait for a reason to exist.

N6.2 — the platform catalog — BUILT 2026-08-25

Nine platform-scope categories were invisible everywhere. A clinic sees the eleven it controls; nobody could see the rest, and "what does this system email people?" had no answer outside the Go source. Console → Notifications lists all twenty, both scopes together, with the classification and the state.

The control is an EMERGENCY STOP, not a settings screen, and the split matters. organization_notification_policy is a clinic deciding what its service does — an ordinary setting it changes and changes back. platform_notification_policy halts a category across every tenant at once, which is what you reach for when a template renders wrongly or a producer loops. Modelling them as one table would have put both gestures on one screen and invited the second to be used as the first.

A reason is required in both directions. A category stopped without a written one is a category nobody can safely turn back on, because the next person cannot tell whether the fault was fixed or whether somebody simply disliked the message. NOT NULL in the schema, refused in the service, prompted for in the UI.

⛔ THE ALARMS REFUSE TO BE STOPPED. notify.UndisableableCategories names four, each with the reason it refuses:

break_glass_openeda clinic is promised this whenever platform staff open elevated access — silencing it removes a compliance control, not a message
sending_pausedthis is what tells a clinic why its patient email stopped; muted, a pause becomes silence nobody can account for
deliverability_warningthe chance to fix a list before being stopped — muting it removes the chance and keeps the consequence
email_change_noticereaches the OLD address while a takeover is still reversible; a security control rather than a notification

A template rendering wrongly in one of these is fixed by fixing the template. The Console shows a lock and the reason rather than a button that errors, because a disabled control teaches nothing.

The stop outranks everything — the clinic's own policy and the classification — but fails open on a lookup error, unlike the classification gate. A database blip is not evidence of an emergency, and refusing every message on one would turn a transient fault into a platform-wide outage of exactly the notices that report faults.

N6.1 — in-app surface (the bell) — PORTAL BUILT 2026-09-04

The line above — "nothing needs building in the primitive, only the read side" — was wrong twice, and both corrections are the interesting part.

  • [x] GET /v1/me/notifications + POST .../{id}/read + POST .../read-all, in a new domain/notifications package beside deliverability. Same primitive from the other end: that one answers a clinic asking "did it arrive?", this one a patient asking "what did you tell me?". They are apart because the disclosure postures are opposite — the staff log withholds message bodies, this one carries them, because a person reading their own correspondence is shown nothing they were not already sent.
  • [x] ⛔ The bell keys on patient_profile_id, NOT on the recipient columns, and a bell built the obvious way would have been empty forever. Every producer addresses its send with notify.ToAddress — the queue-time guard forces it, because no channel adapter resolves a principal to an address — so recipient_principal_id is NULL on every notification the platform sends, and notifications_select_self (the RLS policy that looks like it was written for this) matches nothing. The denormalised profile id added for the staff log in N6 is what makes the bell possible at all.
  • [x] Three producers gained AboutPatient — the reminder sweep's two sends and the form-session link. Without it their messages reach the email and not the bell, and they were equally unattributable in the staff log.
  • [x] Nine categories fan out to in_app: the appointment four, the video notice, the programme, the document, the form link, the subscription warning. Identity and security mail (email_change_*, reconsent_required) deliberately stays email-only — it exists to reach somebody OUTSIDE the app, often when the app itself may be in the wrong hands. Email stays FIRST in every channel list because Send renders from DefaultChannels[0]; a test pins both the set and the ordering.
  • [x] ⛔ The dispatcher's two blocking gates were channel-blind, and it cost nothing until something fanned out in-app. A deliverability pause and a suppression both protect a clinic's SENDING REPUTATION, and neither has anything to say about a row that never leaves the database — but checkBlocked ran them per delivery regardless of channel, so a clinic paused for bouncing would also have gone silent in its patients' bells, and a patient with one dead mailbox would have lost the one channel still working. checkSuppressed's own doc described the right rule and the code implemented it by proxy on the wrong field (the notification's address rather than the delivery's channel), which every email-routed category carries. Now gated on channelNeedsAddress.
  • [x] in_app is excluded from ListPreferences and refused by SetPreference. Always-on by design — the 1A.18 spec says so and the adapter's doc repeats it — so a switch for it would be the dead control ListPreferences already forbids, and it would not mean what a patient read it to mean: switching off an in-app notice does not stop the clinic doing the thing, it only stops the app admitting it happened.
  • [x] Portal surfaces/notifications (client-owned via SWR) and /notifications/settings (server-rendered, N1.5's patient preference UI, which this closes). The bell is not P42-tagged and must not be: the rows are written by the dispatcher goroutine in another process, so no server action could ever invalidate the entry and the bell would freeze at whatever it said on first render. Unread count rides in meta beside the rows so the badge and the list come from one request.
  • [x] The nav row carries the count, and the phone's More button carries a dot — the notifications row lives behind More there, and a badge inside a sheet nobody has opened is a badge nobody sees. Not a fifth tab: the bar's four rows are what a thumb can hit beside More.
  • [ ] Bell in Clinic. Staff have the notification log, which answers the question a clinic actually asks; a staff bell is a different feature and still wants a reason to exist.

N7 — retention (closes G11)

  • [ ] Partition drop for notifications / notification_deliveries at 18 months; periodic DELETE on the flat notification_idempotency_keys at 30 days. Rendered PII makes this a compliance job, not housekeeping.

6. Still open — do not invent answers

  • PDF reports: attach, or link into the Portal? Attaching leaks a clinical document into a mailbox the clinic does not control; linking requires the patient to have a login, which P16 exists precisely because they often do not. Likely per-org policy, but it is a controller decision.
  • Does a guest booking (no humans row) get reminders at all? The address is on the appointment; there is no principal, so there is no preferences row and no opt-out mechanism. An operational send with no opt-out path is the exact thing N0 exists to prevent.
  • S1 — two invite mechanisms. Clerk's provider is wired and works; notify's category is complete and unused. Keeping both means invitations are recorded in notifications half the time.
  • X3 — a quota-threshold warning that the quota itself may block. Platform scope, presumably, but that is an explicit carve-out to write down.
  • Per-locale copy. notify owns its .tmpl files, but Romanian copy quality is a translations concern. Every new category doubles the template count; N3 alone adds twelve files.

7. Cross-references