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:
| Category | Producer at HEAD | Scope | Reaches |
|---|---|---|---|
owner_welcome | organization.Service.Create | platform | org owner |
break_glass_opened | breakglass.Service | platform | clinic admins |
webhook_subscription_paused | webhooks/dispatcher/notifier.go | platform | clinic admins |
content_render_failed | exercises/rendernotify/notifier.go | platform | content owner |
email_change_confirm | server.EmailChangeMailer | platform | anyone, incl. patients |
email_change_notice | server.EmailChangeMailer | platform | anyone, incl. patients |
form_session_link | server.FormSessionLinks | tenant | patient |
member_invite | zero callers ⛔ | platform | invited 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,appointmentdocumentsandvideofornotify.returns zero hits. member_inviteis 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.CreateStaff→auth.InvitationProvider), entirely bypassingnotify. So the platform has two unrelated ways to mail a human, and only one of them is recorded innotifications.
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
| Decision | Ruling | Consequence |
|---|---|---|
| Channels in scope | Email 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 seam | The 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 happens | Per-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 identity | Per-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 flagging | Measure 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)
| # | Notification | Trigger | Class | Notes |
|---|---|---|---|---|
| P1 | Booking confirmation + ICS | appointments create, after commit | transactional | Recipient is appointments.contact_email — patient_profiles has no email column and a guest booking has no humans row. Needs G7. |
| P2 | Reminder (T-24h, T-2h) | scheduled sweep | operational | Lead times are per-org policy. Needs G1, G2, G3, G6. |
| P3 | Rescheduled | appointment time change | transactional | Must revoke P2's pending sends and re-enqueue. |
| P4 | Cancelled by clinic | cancelled_by_clinic | transactional | Must revoke P2. |
| P5 | Appointment confirmed | upcoming → confirmed | operational | The docs already claim this exists. It does not. |
| P6 | Video join link | video.OpenRoom / T-15m before an online appointment | transactional | Link must not outlive the room. |
| P7 | Form requested | ✅ BUILT — form_session_link | transactional | The one working patient path. |
| P8 | Form overdue | sweep over unfilled form_triggers | operational | |
| P9 | Re-consent required | version supersession → the 412 gate | transactional | Today the gate is silent: the patient discovers it only by logging in and being blocked. |
| P10 | Program prescribed | protocols.Create / .Approve | transactional | |
| P11 | Program updated | ApplyMyProgramUpdates, ResumeAfterContentEdit | operational | |
| P12 | Protocol paused / resumed / ended | Pause, Resume, End | operational | Clinical pause only — a content_edit pause is invisible to the patient by design. |
| P13 | Adherence nudge | sweep over missed sessions | operational | The first category a patient will plausibly opt out of. |
| P14 | Subscription expiring / expired | patient-subscriptions-expiry-sweep (cron exists, sends nothing) | transactional | |
| P15 | Report published | appointmentdocuments.Publish | transactional | PDF as attachment or portal link — see §6. Needs G7 if attached. |
| P16 | Patient invite / claim login | invites.Service.CreatePatient | transactional | Delivered by Clerk today, bypassing notify. Endpoint exists with zero callers in any app — no invite-a-patient button. |
| P17 | Account closure confirmed | accountclosure.Close | transactional | |
| P18 | GDPR export ready | gdprexport.BuildForSubject | transactional | Art. 15 response; the link must be single-use and short-lived. |
| P19 | Access offer fulfilled | accessoffers.ProvisionOffer | transactional |
4.2 Staff-facing
| # | Notification | Trigger | Class | Notes |
|---|---|---|---|---|
| S1 | Staff invite | invites.Service.CreateStaff | transactional | Category + 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. |
| S2 | New booking on my calendar | appointment created | operational | Per-specialist opt-out is the point of G2. |
| S3 | Cancelled by patient | cancelled_by_patient / cancelled_late | operational | |
| S4 | No-show recorded | appointment-noshow-sweep | operational | Cron exists; sends nothing. |
| S5 | Patient completed / signed a form | form.completed, form.signed | operational | The events already exist — this is the cheapest staff notification to build. |
| S6 | Patient waiting in video room | video.WithPresenceNotifier | operational | Presence hook exists and notifies nobody. In-app is the right channel; email is too slow to matter. |
| S7 | Daily schedule digest | scheduled | operational | |
| S8 | Patient subscription lapsing | expiry sweep | operational | Front-desk copy of P14. |
| S9 | Ownership transfer initiated / accepted / declined / cancelled | 4 events already registered | transactional | Currently a silent state change on the org's ownership. |
| S10 | Break-glass opened | ✅ BUILT | transactional | |
| S11 | Webhook subscription paused | ✅ BUILT | transactional | |
| S12 | Content render failed | ✅ BUILT | transactional |
4.3 Platform / owner-facing
| # | Notification | Trigger | Status |
|---|---|---|---|
| X1 | Owner welcome | organization.Service.Create | ✅ BUILT |
| X2 | Email change confirm / notice | server.EmailChangeMailer | ✅ BUILT |
| X3 | Quota threshold reached | 1C.7 metering | not built — the first thing a clinic hitting emails_sent_per_month should hear about, and ironically it would be blocked by the same quota |
| X4 | Org billing events | orgbilling | not 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.SendreadsCatalog()[cat].Classificationand branches:transactionalsends;operationalconsultsnotification_preferences. An unrecognised classification is refused, not sent. - [x]
marketingfails closed rather than shipping an unexercised ledger read. The consents ledger keys onpatient_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_NoMarketingCategoryUntilLedgerReadExistssays so at build time. - [x] A suppressed send records why: a terminal
suppresseddelivery status plusnotification_deliveries.suppressed_reason, tied together in both directions bychk_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 aPRIMARY KEYto two partial unique indexes: a PK cannot hold NULL, platform-scope rows need a NULL org, and a plainUNIQUEwould let one recipient accumulate unlimited platform rows for a(category, channel)since NULL never equals NULL. - [x] Dropped
notifications_select_org_memberoutright 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 inrlstest/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:
| Tier | From line | What it costs the clinic |
|---|---|---|
| Default | Clinica 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 |
| Verified | Clinica 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_namewhen set, otherwise the organisation'sname, 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_providersoverride row that already exists — the table has a nullableorganization_id, a partial unique index per (capability, org), and the documented resolutionORDER BY organization_id NULLS LAST LIMIT 1.configJSONB carriesfrom_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_setper org, created at onboarding. - [ ] Event destination → SNS (bounce, complaint, delivery).
- [ ] Message tags on every send:
org_idanddelivery_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.
Deliveryevents 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 thecheck-inbound-webhooksguard 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:
| Reason | Scope | Why |
|---|---|---|
Hard bounce (Permanent) | platform-wide | the mailbox does not exist for anybody; re-sending from another clinic burns the same account reputation |
| Complaint | org-scoped | "I do not want this clinic's mail" is a statement about one relationship, not about the address |
| Manual | either, explicitly | support 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_idontonotification_deliverieswith 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_pausedcategories,enandro. - [ ] Console surface — every org ranked by risk.
DeliverabilityRankedis built and paginated; nothing renders it yet. - [ ] Clinic surface — the clinic's own counts, rates and band.
DeliverabilityForis built; nothing renders it yet. - [ ] The lift action needs a Console endpoint.
LiftSendingPauseexists 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.
| Band | Bounce | Complaint | What happens |
|---|---|---|---|
| healthy | < 2% | < 0.05% | nothing |
| warning | 2–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_warningandsending_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— done in N0, which is where the first reader landed.notification_preferences[x] Per-org notification policy —
organization_notification_policy, sparse, plus the gate inSend,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 times— moved 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 UI — BUILT 2026-09-04 with N6.1;
/notifications/settings. It was pointless while every category was transactional;appointment_reminderandvideo_consultation_startingare 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 domains | restartix.ro, restartix.pro, restartix.online ✅ · restartix.eu FAILED · dr-fix.us disabled |
| Configuration sets | restartix-production, restartix-staging, and bm_decad65b… — not ours, some other tool |
| Suppression list | 4,864 addresses, account-level, BOUNCE + COMPLAINT both enabled, all 2022-era |
| Dedicated IPs | none |
| Event destinations | none — nothing captures bounces (this is G5) |
| Tenants | API 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;RevokeScheduledcalls 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.
attemptsis 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 toService.Book.[x] The
internal/shared/icalgenerator. 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_bookedis 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_reminderis 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.IssueJoinstates 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.confirmedis 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 inbooked, 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
appointmentsdomain (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 producer —
appointmentdocuments.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_inviteor 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-logplus a separate body read, and both surfaces over them: the clinic-wide list underOrganization → 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 onorganizations.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 reasonaudit_loghas one: the recipient columns cannot answer "what did we send this person". An address changes, a guest booking carries no principal, and a join throughhumans.emailfinds 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
patientsid, not a profile id. A profile spans clinics; apatientsrow does not, so a clinic cannot name another clinic's patient even by guessing. Same rule F15 states for profile predicates: joinpatientsat the searching org. - [ ] The bell —
notification_deliveries.read_athas 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_opened | a clinic is promised this whenever platform staff open elevated access — silencing it removes a compliance control, not a message |
sending_paused | this is what tells a clinic why its patient email stopped; muted, a pause becomes silence nobody can account for |
deliverability_warning | the chance to fix a list before being stopped — muting it removes the chance and keeps the consequence |
email_change_notice | reaches 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 newdomain/notificationspackage besidedeliverability. 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 withnotify.ToAddress— the queue-time guard forces it, because no channel adapter resolves a principal to an address — sorecipient_principal_idis NULL on every notification the platform sends, andnotifications_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 becauseSendrenders fromDefaultChannels[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
checkBlockedran 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 onchannelNeedsAddress. - [x]
in_appis excluded fromListPreferencesand refused bySetPreference. 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 controlListPreferencesalready 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 inmetabeside 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_deliveriesat 18 months; periodicDELETEon the flatnotification_idempotency_keysat 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
humansrow) 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
notificationshalf 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
.tmplfiles, but Romanian copy quality is a translations concern. Every new category doubles the template count; N3 alone adds twelve files.
7. Cross-references
- foundation.md → 1A.18 — the primitive's original design and the F7-webhook-vs-notify boundary ("two transports, one event").
- patterns.md → P50 — the
notify.Channelcarve-out from the capability convention. - data-classification.md → notifications — egress rows; a new column needs a registry entry in the same PR.
- events.md — the publishing convention N3–N5 depend on.
- platform-completion.md — where the launch-blocking framing and the retention table came from.