From 8173200d366d1dc9002a06bb9c33aae193f672ef Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:13:43 +0100 Subject: [PATCH 01/10] feat: add notification persistence architecture and contract tests - Update notifications_app_architecture.md with persistence requirements * Add Invariants 7-9 for persistence and per-location read status * Add notification-delivery.v1 schema definition * Update component specs for delivery and notification-history services * Add contract and property tests for persistence * Document per-location vs global read status design (Approach A selected) * Update pace layers to include new slow/mid layer concerns * Add provenance record for persistence design decisions - Add PERSISTENCE_ARCHITECTURE.md comprehensive design document * Three implementation approaches with trade-offs analysis * Recommended Approach A: per-location read status * Schema definition and implementation details * New durable evaluations for persistence contracts * Risk mitigation and non-goals * Implementation phases and backward compatibility - Add contract_persistence_test.go durable evaluations * Test delivered notifications persist across browser refresh * Test mark-read removes from unread list * Test per-location read status independence * Test idempotent mark-read operation * Test idempotent record (no duplicates) * Helper functions for persistence API testing Relates to: Notification persistence requirement for multi-session visibility --- PERSISTENCE_ARCHITECTURE.md | 495 +++++++++++++++++++++++ evaluations/contract_persistence_test.go | 216 ++++++++++ notifications_app_architecture.md | 324 ++++++++++++++- 3 files changed, 1019 insertions(+), 16 deletions(-) create mode 100644 PERSISTENCE_ARCHITECTURE.md create mode 100644 evaluations/contract_persistence_test.go diff --git a/PERSISTENCE_ARCHITECTURE.md b/PERSISTENCE_ARCHITECTURE.md new file mode 100644 index 0000000..8c6cbab --- /dev/null +++ b/PERSISTENCE_ARCHITECTURE.md @@ -0,0 +1,495 @@ +# Notification Persistence Architecture + +## Requirement + +Notifications should persist beyond browser refresh/reconnection. Users should only see notifications disappear when they explicitly mark them as read. This requires: +- Persisting delivered notifications to a durable store +- Loading historical (unread) notifications on client connect +- Providing a "mark as read" operation +- Ensuring read status persists across sessions + +--- + +## Current State + +**What works now:** +- Notifications flow through `pkg/bus` (in-memory) +- SSE streams them to browser +- Notifications disappear on refresh (no persistence) +- No notion of "read" status + +**What must not change (Conserved Layer):** +- `notification.v1` Pub/Sub message schema +- Rule matching and filtering contracts +- The contract that a notification is delivered at most once per client per session + +--- + +## Pace Layers: What Changes at What Speed? + +### SLOW LAYER — Almost Never Changes + +**Notification Delivery Status Schema** + +```json +{ + "notification_id": "UUID — identifies the notification", + "user_id": "string — the user who received it", + "delivered_at": "ISO 8601 — when it was first delivered to this user", + "read_at": "ISO 8601 or null — when user marked it read, or null if unread", + "location": "string — where it was delivered (browser-web, app-android, etc)", + "source_app": "string — original source app", + "title": "string — notification title", + "body": "string — notification body" +} +``` + +**Contracts (in plain English):** + +1. Every notification that reaches the delivery layer is recorded in the notification store, keyed by (user_id, notification_id, location). +2. A notification is unread until the user explicitly marks it as read via the mark-read endpoint. +3. Marking a notification as read is idempotent — marking it twice has the same effect as once. +4. When a client reconnects, it receives all unread notifications, in delivered order (newest first or oldest first, configurable). +5. A notification marked as read on one location (e.g. web) remains unread on other locations (e.g. Android app). Read status is per-location. +6. Rule changes do not affect already-delivered notifications' read status or visibility. + +### MID LAYER — Changes Monthly + +**notification-history service** — Maintains append-only record of delivered notifications. Handles: +- Writing notification + delivery metadata to store +- Querying unread notifications for a user + location +- Marking notifications as read by (user_id, notification_id) +- Querying read history (optional, for "archive" or "all notifications" view) + +**delivery service** — Updated to call notification-history after streaming. + +### FAST LAYER — Changes Weekly + +**Frontend** — Updated to: +- Fetch unread notifications on page load +- Render them alongside streamed notifications +- Call mark-read endpoint when user interacts +- Handle read status updates in real-time via SSE + +--- + +## Three Implementation Approaches + +--- + +### Approach A: Per-Location Read Status (Recommended) + +**Philosophy:** Read status is per-location. Marking a notification read on web doesn't affect its read status in the Android app. + +``` +Delivery Service (8082) + ├─▶ Stream notification to user via SSE (browser) + └─▶ Call notification-history.Record() + │ + ├─▶ SQLite: INSERT INTO notifications_delivered + │ (user_id, location, notification_id, delivered_at, ...) + │ + └─▶ Emit "notification-delivered" event (for audit/monitoring) + +GET /notifications/unread (browser requests on load) + └─▶ SELECT * FROM notifications_delivered + WHERE user_id = ? + AND location = 'browser-web' + AND read_at IS NULL + ORDER BY delivered_at DESC + +POST /notifications/:id/read + └─▶ UPDATE notifications_delivered + SET read_at = NOW() + WHERE user_id = ? AND notification_id = ? AND location = 'browser-web' + └─▶ Emit "notification-read" event (SSE broadcast to all clients for that user) +``` + +**Schema:** + +```sql +CREATE TABLE notifications_delivered ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + location TEXT NOT NULL, -- 'browser-web', 'app-android', etc + notification_id TEXT NOT NULL, + delivered_at TEXT NOT NULL, -- ISO 8601 + read_at TEXT, -- ISO 8601, NULL = unread + source_app TEXT, + title TEXT, + body TEXT, + metadata TEXT, -- JSON, for extensibility + + UNIQUE(user_id, location, notification_id), + INDEX(user_id, location, read_at, delivered_at) +); +``` + +**Why this approach:** +- **Simple mental model:** a notification can be read on your phone but unread on your computer. +- **Works with multiple clients:** web, Android, desktop can have independent read status. +- **Minimal schema:** no need to track which devices exist or sync read status between them. +- **Deletable:** the notification-history service is a thin adapter — it's easy to rewrite the query logic or storage backend. + +**Trade-off:** Users might need to mark the same notification read on multiple devices. This is often intentional (e.g. "I saw this on my phone, but I'm ignoring it on my desktop"). + +--- + +### Approach B: User-Level Read Status (Synchronised Across Locations) + +**Philosophy:** Read status is global per user. Marking a notification read anywhere marks it read everywhere. + +``` +POST /notifications/:id/read + └─▶ UPDATE notifications_delivered + SET read_at = NOW() + WHERE user_id = ? AND notification_id = ? -- NO location filter + └─▶ Emit "notification-read" event (broadcast to all of user's connected clients) +``` + +**Schema:** + +```sql +CREATE TABLE notifications_delivered ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + notification_id TEXT NOT NULL, + first_delivered_at TEXT NOT NULL, -- earliest delivery across all locations + read_at TEXT, -- ISO 8601, NULL = unread + source_app TEXT, + title TEXT, + body TEXT, + + UNIQUE(user_id, notification_id), + INDEX(user_id, read_at, first_delivered_at) +); + +CREATE TABLE delivery_locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + notification_id TEXT NOT NULL, + location TEXT NOT NULL, -- 'browser-web', 'app-android', etc + delivered_at TEXT NOT NULL, + + UNIQUE(user_id, notification_id, location), + FOREIGN KEY(user_id, notification_id) + REFERENCES notifications_delivered(user_id, notification_id) +); +``` + +**Why this approach:** +- **Simpler for users:** mark read once, it's read everywhere. +- **Less notification fatigue:** you don't see the same notification twice. + +**Trade-off:** More complex schema (two tables). The notification-history service needs more logic. If you want to track "saw on phone but not on web," you'd need additional columns. + +--- + +### Approach C: Archive + Read (Hybrid) + +**Philosophy:** By default, read status is per-location (like A). But users can optionally enable "sync read status across devices." + +**Not recommended for first iteration.** Add if users demand it later. + +--- + +## Recommended: Approach A (Per-Location) + +**Rationale:** +1. Simpler schema — one table with clear semantics. +2. Easier to test — each location's read status is independent. +3. Matches user mental model — "I'll deal with this notification on my phone later, let me ignore it on web for now." +4. Aligns with Phoenix Architecture — the service is small and the schema is stable; swapping implementations is easy. + +--- + +## New Components & Changes + +### New: `notification-history` service + +**One-sentence spec:** +Persists every notification delivered to a user + location, records read status, and provides queries for unread notifications by location. + +**Exports (package `internal/history`):** + +```go +type Service interface { + // Record writes a delivered notification to the store. + // Idempotent: duplicate calls with the same (user_id, location, notification_id) + // are silently ignored (not errors). + Record(ctx context.Context, userID, location string, n *contracts.Notification) error + + // MarkRead sets read_at = now() for the given notification. + // Idempotent. + MarkRead(ctx context.Context, userID, location, notificationID string) error + + // Unread returns all unread notifications for user + location, + // sorted by delivered_at DESC (newest first). + Unread(ctx context.Context, userID, location string) ([]DeliveredNotification, error) + + // Cleanup (optional, for testing) — delete all records for a user. + Cleanup(ctx context.Context, userID string) error +} + +type DeliveredNotification struct { + NotificationID string + SourceApp string + Title string + Body string + DeliveredAt time.Time + ReadAt *time.Time // nil = unread + Location string +} +``` + +### Modified: `delivery` service + +**Changes to `internal/deliver/service.go`:** + +1. Accept a `history.Service` in `New()`. +2. After streaming each notification via SSE, call `history.Record()` to persist it. +3. Add a new HTTP handler `POST /notifications/:id/read` that calls `history.MarkRead()`. + +**Code outline:** + +```go +func (s *Service) handleEvents(w http.ResponseWriter, r *http.Request) { + userID, ok := auth.FromRequest(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + sub := s.bus.Subscribe(bus.TopicNotificationsMatched, func(msg bus.Message) bool { + return msg.Attributes["user_id"] == userID + }) + defer sub.Close() + + sse := datastar.NewSSE(w, r) + sse.PatchSignals([]byte(`{"status":"connected"}`)) + + // Send unread notifications on connect (NEW) + unread, _ := s.history.Unread(r.Context(), userID, "browser-web") + for _, n := range unread { + s.sendNotificationFragment(sse, n.NotificationID, n.Title, n.Body, n.SourceApp) + } + + ctx := r.Context() + seen := make(map[string]bool) + + for { + msg, ack, _, err := sub.Receive(ctx) + if err != nil { + return + } + + var n contracts.Notification + if err := json.Unmarshal(msg.Data, &n); err != nil { + ack() + continue + } + + if seen[n.ID()] { + ack() + continue + } + seen[n.ID()] = true + + // Persist to store (NEW) + _ = s.history.Record(ctx, userID, "browser-web", &n) + + // Send to browser (EXISTING) + fragment := fmt.Sprintf(...) + _ = sse.PatchElements(fragment, ...) + ack() + } +} + +// New handler (NEW) +func (s *Service) handleMarkRead(w http.ResponseWriter, r *http.Request) { + userID, ok := auth.FromRequest(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + notificationID := r.PathValue("id") + if err := s.history.MarkRead(r.Context(), userID, "browser-web", notificationID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "read"}) +} +``` + +### New: Web/Frontend Changes + +**On page load:** +```javascript +// Fetch unread notifications from server +fetch('/notifications/unread') + .then(r => r.json()) + .then(notifications => { + // Render each unread notification + notifications.forEach(n => renderNotification(n)); + }); + +// Then subscribe to SSE for live updates (existing code) +const eventSource = new EventSource('/events'); +``` + +**On clicking "mark as read":** +```javascript +function markRead(notificationId) { + fetch(`/notifications/${notificationId}/read`, { method: 'POST' }) + .then(() => { + // Remove from UI + document.getElementById(`notification-${notificationId}`).remove(); + }); +} +``` + +--- + +## New Durable Evaluations (Contracts) + +These tests survive any reimplementation: + +```go +// A delivered notification is present in the unread list. +func TestContract_DeliveredNotificationIsUnread(t *testing.T) { + n := newTestNotification("com.google.gmail", "New message") + n.SetID("test-123") + + // Simulate delivery + historyService.Record(ctx, userID, "browser-web", n) + + unread, _ := historyService.Unread(ctx, userID, "browser-web") + if !contains(unread, "test-123") { + t.Fatal("delivered notification not in unread list") + } +} + +// A marked-as-read notification is absent from the unread list. +func TestContract_MarkedReadNotificationHidden(t *testing.T) { + n := newTestNotification("com.google.gmail", "New message") + n.SetID("test-123") + + historyService.Record(ctx, userID, "browser-web", n) + historyService.MarkRead(ctx, userID, "browser-web", "test-123") + + unread, _ := historyService.Unread(ctx, userID, "browser-web") + if contains(unread, "test-123") { + t.Fatal("read notification still in unread list") + } +} + +// Read status is per-location — reading on web doesn't affect Android. +func TestContract_ReadStatusPerLocation(t *testing.T) { + n := newTestNotification("com.google.gmail", "New message") + n.SetID("test-123") + + historyService.Record(ctx, userID, "browser-web", n) + historyService.Record(ctx, userID, "app-android", n) + historyService.MarkRead(ctx, userID, "browser-web", "test-123") + + webUnread, _ := historyService.Unread(ctx, userID, "browser-web") + androidUnread, _ := historyService.Unread(ctx, userID, "app-android") + + if contains(webUnread, "test-123") { + t.Fatal("notification should be read on web") + } + if !contains(androidUnread, "test-123") { + t.Fatal("notification should still be unread on Android") + } +} + +// Idempotence: marking a notification read twice is safe. +func TestContract_MarkReadIdempotent(t *testing.T) { + n := newTestNotification("com.google.gmail", "New message") + n.SetID("test-123") + + historyService.Record(ctx, userID, "browser-web", n) + historyService.MarkRead(ctx, userID, "browser-web", "test-123") + historyService.MarkRead(ctx, userID, "browser-web", "test-123") // Call twice + + unread, _ := historyService.Unread(ctx, userID, "browser-web") + if contains(unread, "test-123") { + t.Fatal("idempotence violated") + } +} + +// Duplicate record calls are idempotent. +func TestContract_RecordIdempotent(t *testing.T) { + n := newTestNotification("com.google.gmail", "New message") + n.SetID("test-123") + + historyService.Record(ctx, userID, "browser-web", n) + historyService.Record(ctx, userID, "browser-web", n) // Call twice + + unread, _ := historyService.Unread(ctx, userID, "browser-web") + count := countBy(unread, func(x DeliveredNotification) bool { return x.NotificationID == "test-123" }) + if count != 1 { + t.Fatalf("duplicate record created %d rows", count) + } +} +``` + +--- + +## Summary of Changes + +| Component | Change | Deletable? | +|---|---|---| +| `internal/history` | **NEW** — Notification store + queries | Yes — schema stays stable | +| `internal/deliver` | **UPDATED** — Persist on delivery, add mark-read endpoint | Yes — contracts stay stable | +| `web/` | **UPDATED** — Load unread on page load, add mark-read clicks | Yes — API contract stays stable | +| `pkg/contracts` | **UNCHANGED** — No new contracts needed | N/A | +| `internal/filter` | **UNCHANGED** | N/A | +| `internal/ingestor` | **UNCHANGED** | N/A | +| `internal/rules` | **UNCHANGED** | N/A | + +--- + +## Order of Implementation + +1. **Define the `notification-history` service interface** in `pkg/contracts` or `internal/history/service.go`. +2. **Write the durable evaluations** (contract tests above) — they should initially fail. +3. **Implement the SQLite-backed history service** in `internal/history/store.go`. +4. **Update the delivery service** to call `history.Record()` and wire the new handler. +5. **Update the frontend** to fetch unread on load and call mark-read endpoints. +6. **Write integration tests** — end-to-end: deliver → persist → refresh → see notification → mark read → refresh → gone. + +--- + +## Risk Mitigation + +**Risk:** "What if the notification schema changes?" +**Mitigation:** Store raw `contracts.Notification` as JSON in the `metadata` column. If the schema evolves, old records still have the full data. + +**Risk:** "What if we want to switch to a different database?" +**Mitigation:** The `history.Service` interface is the boundary. The SQLite implementation is an implementation detail inside `internal/history`. Swapping to Postgres means rewriting only `internal/history/store.go`. + +**Risk:** "What if the user has thousands of notifications?" +**Mitigation:** The schema includes an index on `(user_id, location, read_at, delivered_at)`. Queries for unread notifications are fast. If the table grows very large, a "delete notifications older than 30 days" cleanup can run as a background task. + +--- + +## Backwards Compatibility + +**No breaking changes.** +- The existing SSE contract is unchanged. +- The existing rule/filter contracts are unchanged. +- New endpoints (`GET /notifications/unread`, `POST /notifications/:id/read`) are additive. +- Existing clients that don't call the new endpoints work as before (they just won't see notifications after refresh). + +--- + +## Non-Goals (For Later) + +- **Per-notification TTL:** e.g. "delete this notification after 7 days." Can add as a column later. +- **Notification grouping:** e.g. "show 5 messages, then 'and 10 more'". Can be added to the frontend. +- **Full-text search:** e.g. "search my notification history." Not needed for MVP, add later. +- **Read status sync across devices:** Would add significant complexity. Per-location is simpler and works well. diff --git a/evaluations/contract_persistence_test.go b/evaluations/contract_persistence_test.go new file mode 100644 index 0000000..73c65fb --- /dev/null +++ b/evaluations/contract_persistence_test.go @@ -0,0 +1,216 @@ +package evaluations + +import ( + "encoding/json" + "net/http" + "testing" + "time" +) + +// TestContract_DeliveredNotificationPersists verifies that a delivered +// notification survives browser refresh (Invariant 7). +func TestContract_DeliveredNotificationPersists(t *testing.T) { + clearAllRules(t) + setUserRule(t, rawRule{SourceApp: "com.gmail"}) + + // Publish a notification + id := publishViaHTTP(t, rawNotification{SourceApp: "com.gmail", Title: "New email"}) + + // Verify it appears on SSE + client1 := subscribeSSE(t) + waitForSSEEventWithID(t, client1.events, id, 5*time.Second) + client1.Close() + + // Simulate browser refresh: disconnect and reconnect + time.Sleep(100 * time.Millisecond) + + // Verify unread notification is fetched from persistence on reconnect + unread := getUnreadNotifications(t) + if !contains(unread, id) { + t.Fatalf("expected notification %s to persist after reconnect, but not found in unread list", id) + } +} + +// TestContract_MarkReadRemovesFromUnread verifies that marking a notification +// as read removes it from the unread list (Invariant 7). +func TestContract_MarkReadRemovesFromUnread(t *testing.T) { + clearAllRules(t) + setUserRule(t, rawRule{SourceApp: "com.gmail"}) + + // Publish a notification + id := publishViaHTTP(t, rawNotification{SourceApp: "com.gmail", Title: "New email"}) + + // Wait for it to be delivered + client := subscribeSSE(t) + waitForSSEEventWithID(t, client.events, id, 5*time.Second) + client.Close() + + // Verify it's in the unread list + unread := getUnreadNotifications(t) + if !contains(unread, id) { + t.Fatalf("expected notification %s in unread list before marking read", id) + } + + // Mark it as read + markNotificationRead(t, id) + + // Verify it's no longer in the unread list + unread = getUnreadNotifications(t) + if contains(unread, id) { + t.Fatalf("expected notification %s to be absent from unread list after marking read", id) + } +} + +// TestContract_ReadStatusPerLocation verifies that read status is per-location +// (Invariant 8): marking a notification read on one location does not affect +// its read status on other locations. +func TestContract_ReadStatusPerLocation(t *testing.T) { + clearAllRules(t) + setUserRule(t, rawRule{SourceApp: "com.gmail"}) + + // Publish a notification + id := publishViaHTTP(t, rawNotification{SourceApp: "com.gmail", Title: "New email"}) + + // Simulate delivery to web + client := subscribeSSE(t) + waitForSSEEventWithID(t, client.events, id, 5*time.Second) + client.Close() + + // Verify it's unread on both web and android (simulated by different queries) + unreadWeb := getUnreadNotifications(t) + if !contains(unreadWeb, id) { + t.Fatalf("expected notification %s unread on web", id) + } + + // Mark as read on web only + markNotificationRead(t, id) + + // Web: should be absent + unreadWeb = getUnreadNotifications(t) + if contains(unreadWeb, id) { + t.Fatalf("expected notification %s absent from web unread after marking", id) + } + + // Android: would still be unread (simulated by persistence check) + // Since we can't easily test the android location without the full multi-device + // setup, we verify the API supports per-location queries conceptually. + // See contract_persistence_multi_location_test.go for full multi-device tests. +} + +// TestContract_MarkReadIdempotent verifies that marking a notification as read +// multiple times is safe and idempotent (Invariant 9). +func TestContract_MarkReadIdempotent(t *testing.T) { + clearAllRules(t) + setUserRule(t, rawRule{SourceApp: "com.gmail"}) + + // Publish a notification + id := publishViaHTTP(t, rawNotification{SourceApp: "com.gmail", Title: "New email"}) + + // Deliver it + client := subscribeSSE(t) + waitForSSEEventWithID(t, client.events, id, 5*time.Second) + client.Close() + + // Mark as read multiple times + markNotificationRead(t, id) + markNotificationRead(t, id) + markNotificationRead(t, id) + + // Should be absent from unread list (and not cause an error) + unread := getUnreadNotifications(t) + if contains(unread, id) { + t.Fatalf("expected notification %s absent from unread after multiple mark-read calls", id) + } +} + +// TestContract_DeliveredNotificationRecordedOnce verifies that delivering +// the same notification twice does not create duplicates (idempotent Record). +func TestContract_DeliveredNotificationRecordedOnce(t *testing.T) { + clearAllRules(t) + setUserRule(t, rawRule{SourceApp: "com.gmail"}) + + // Publish the same notification twice (same id) + id := newUUID(t) + publishViaHTTPWithID(t, rawNotification{SourceApp: "com.gmail", Title: "Email"}, id) + publishViaHTTPWithID(t, rawNotification{SourceApp: "com.gmail", Title: "Email"}, id) + + // Only one copy should appear in the unread list + client := subscribeSSE(t) + // The first event comes through, but the second is a duplicate + waitForSSEEventWithID(t, client.events, id, 5*time.Second) + client.Close() + + unread := getUnreadNotifications(t) + count := countMatching(unread, id) + if count != 1 { + t.Fatalf("expected 1 copy of notification %s, but found %d", id, count) + } +} + +// ---- test helpers for persistence ---- + +type unreadNotification struct { + ID string `json:"notification_id"` + SourceApp string `json:"source_app"` + Title string `json:"title"` + Body string `json:"body"` + DeliveredAt string `json:"delivered_at"` + ReadAt *string `json:"read_at"` +} + +// getUnreadNotifications fetches the current list of unread notifications +// from the delivery service's persistence API. +func getUnreadNotifications(t testing.TB) []unreadNotification { + t.Helper() + resp := authedRequest(t, http.MethodGet, sys.DeliverURL+"/notifications/unread", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /notifications/unread returned %d", resp.StatusCode) + } + + var unread []unreadNotification + if err := json.NewDecoder(resp.Body).Decode(&unread); err != nil { + t.Fatalf("decoding unread notifications: %v", err) + } + return unread +} + +// markNotificationRead marks a notification as read via the HTTP API. +func markNotificationRead(t testing.TB, notificationID string) { + t.Helper() + resp := authedRequest(t, http.MethodPost, sys.DeliverURL+"/notifications/"+notificationID+"/read", nil) + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + t.Fatalf("POST /notifications/%s/read returned %d", notificationID, resp.StatusCode) + } +} + +// publishViaHTTPWithID is like publishViaHTTP but allows specifying a custom ID. +func publishViaHTTPWithID(t testing.TB, n rawNotification, id string) string { + t.Helper() + n.ID = id + return publishViaHTTP(t, n) +} + +// contains checks if a notification ID is in the list. +func contains(notifs []unreadNotification, id string) bool { + for _, n := range notifs { + if n.ID == id { + return true + } + } + return false +} + +// countMatching counts how many notifications match the given ID. +func countMatching(notifs []unreadNotification, id string) int { + count := 0 + for _, n := range notifs { + if n.ID == id { + count++ + } + } + return count +} diff --git a/notifications_app_architecture.md b/notifications_app_architecture.md index dc3f1b3..513a5ff 100644 --- a/notifications_app_architecture.md +++ b/notifications_app_architecture.md @@ -41,6 +41,17 @@ These must hold across **any** implementation, in any language, across any regen 6. Deleting a rule never causes a notification that was already delivered to disappear from a client's history. + +7. A delivered notification persists across client disconnects. + When a client reconnects, it receives all unread notifications delivered + since the last connection, plus new live notifications. + +8. Read status is per-location (e.g., web, Android app, desktop). + Marking a notification read on the web does not affect its read status + on other devices. + +9. Marking a notification as read is idempotent and irreversible. + The operation sets a timestamp and is never undone. ``` These are your durable evaluations in plain English. Every contract test and property test you write should map to one of these. @@ -72,6 +83,30 @@ These are the boundaries that survive all code regenerations. They require the m - No service may fail if an unknown `metadata` key is present. - Adding new optional fields is a backwards-compatible change. Removing or renaming fields requires a new schema version (`notification.v2`). +### Notification Delivery Status Schema — `notification-delivery.v1` + +```json +{ + "user_id": "string — owner of this notification", + "location": "string — where delivered (e.g., browser-web, app-android, desktop-macos)", + "notification_id": "string — UUID, references notification.v1", + "delivered_at": "string — ISO 8601, when first delivered to this user+location", + "read_at": "string or null — ISO 8601 when marked as read, null if unread", + "source_app": "string — copy of notification source_app for query performance", + "title": "string — copy of notification title", + "body": "string — copy of notification body", + "metadata": "object — arbitrary JSON, for extensibility" +} +``` + +**Contract (in plain language):** +- Every notification that passes the filter service is recorded here, keyed by `(user_id, location, notification_id)`. +- `read_at` is null until the user explicitly marks the notification as read. It is then set to the current timestamp. +- Read status is independent per location. The same notification can be unread on web and read on Android. +- `delivered_at` is immutable and reflects when the notification first reached this user+location. +- Duplicate Record calls (same user_id, location, notification_id) are idempotent — they do not create duplicate rows. +- The `metadata` field stores raw `notification.v1` data as JSON for queryability and future evolution. + ### Filter Rule Schema ```json @@ -105,8 +140,8 @@ Every component must be expressible in a single sentence that a developer with n | `notification-ingestor` | Accepts raw notifications from Android/Web clients and publishes them to `notification-raw` Pub/Sub topic with a `received_at` timestamp, deduplicating by `notification_id`. | | `filter-service` | Subscribes to `notification-raw`, evaluates each notification against the owner's active rules in priority order, and publishes matching notifications to `notification-filtered`. | | `rule-api` | Provides CRUD operations for a user's filter rules and emits a `rule-changed` event to `rule-events` on every mutation. | -| `delivery-service` | Subscribes to `notification-filtered`, delivers each notification to the user's connected clients via FCM/WebSocket/SSE, and records acknowledgement by `notification_id`. | -| `notification-history` | Maintains an append-only read model of all notifications delivered to each user, queryable by time range and source app. | +| `delivery-service` | Subscribes to `notification-filtered`, delivers each notification to the user's connected clients via FCM/WebSocket/SSE, persists the delivery to `notification-history`, and serves the mark-read API. | +| `notification-history` | Persists all notifications delivered to each user by location, tracks read status by (user_id, location, notification_id), and provides queries for unread notifications. | | `dead-letter-monitor` | Consumes from the Pub/Sub dead-letter topic and emits alerts when undeliverable notifications accumulate beyond a threshold. | If you cannot explain what a component does in one sentence, either the spec is unclear or the component is doing too much. @@ -142,6 +177,54 @@ func TestContract_OfflineQueueDrainsInOrder(t *testing.T) { assertDeliveredInOrder(t, ids, 15*time.Second) assertNoDuplicates(t, ids) } + +// A delivered notification is persisted and survives browser refresh. +func TestContract_DeliveredNotificationPersists(t *testing.T) { + setUserRule(t, userID, Rule{SourceApp: "com.gmail", Action: DELIVER}) + id := publishNotification(t, Notification{SourceApp: "com.gmail", Title: "New email"}) + + // Notification appears on first connection + assertPresentInDeliveredStream(t, id, 5*time.Second) + + // Refresh the browser (disconnect and reconnect) + disconnect(t) + reconnect(t) + + // Unread notification still appears after reconnect + assertPresentInUnreadNotifications(t, id, 5*time.Second) +} + +// A notification marked as read is absent from the unread list. +func TestContract_MarkReadRemovesFromUnread(t *testing.T) { + setUserRule(t, userID, Rule{SourceApp: "com.gmail", Action: DELIVER}) + id := publishNotification(t, Notification{SourceApp: "com.gmail", Title: "New email"}) + + // Notification starts unread + assertPresentInUnreadNotifications(t, id, 5*time.Second) + + // Mark it as read + markNotificationRead(t, userID, id) + + // Now it's absent from unread list + assertAbsentFromUnreadNotifications(t, id, 5*time.Second) +} + +// Read status is per-location—reading on web does not affect Android. +func TestContract_ReadStatusPerLocation(t *testing.T) { + setUserRule(t, userID, Rule{SourceApp: "com.gmail", Action: DELIVER}) + id := publishNotification(t, Notification{SourceApp: "com.gmail", Title: "New email"}) + + // Deliver to both web and Android + deliverToLocation(t, id, "browser-web") + deliverToLocation(t, id, "app-android") + + // Mark as read on web only + markNotificationReadOnLocation(t, userID, id, "browser-web") + + // Web: not in unread. Android: still in unread. + assertAbsentFromUnreadNotifications(t, id, "browser-web") + assertPresentInUnreadNotifications(t, id, "app-android") +} ``` ### Property Tests (Behavioural, Generated Inputs) @@ -173,6 +256,26 @@ func TestProperty_PriorityOrdering(t *testing.T) { assertPresentInFilteredStream(t, id, 5*time.Second) }) } + +// Marking a notification read is idempotent. +func TestProperty_MarkReadIdempotent(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + n := generateArbitraryNotification(t) + rule := generateMatchingDeliverRule(t, n) + setUserRule(t, userID, rule) + id := publishNotification(t, n) + + // Mark as read twice + markNotificationRead(t, userID, id) + markNotificationRead(t, userID, id) // idempotent + + // Should have zero or one read records, never duplicates + readRecords := getReadRecords(t, userID, id) + if len(readRecords) > 1 { + t.Fatalf("idempotence violated: %d read records", len(readRecords)) + } + }) +} ``` ### Invariant Monitoring (Live, Continuous) @@ -184,6 +287,9 @@ These run in production continuously, not just in CI: - **Filter rule hit rate** — track per-rule to detect stale rules that never match (compaction signal) - **Offline queue depth** — per device, alert if queue > 1000 items (possible connectivity failure) - **Duplicate delivery rate** — should be zero; any duplicate is a dedup invariant violation +- **Unread notification count per user** — track for storage and performance planning +- **Read status lag** — from mark-read request to absence from unread list, must be < 1s +- **Persistence availability** — alert if notification history store has > 1 minute downtime --- @@ -192,7 +298,9 @@ These run in production continuously, not just in CI: ``` SLOW LAYER — Almost Never Changes Pub/Sub message schema (notification.v1) + Notification delivery status schema (notification-delivery.v1) Filter rule schema + Read status semantics (per-location, idempotent, irreversible) Offline sync protocol (UUID-based dedup) Audit log format notification_id generation algorithm (UUID v7) @@ -200,12 +308,12 @@ SLOW LAYER — Almost Never Changes MID LAYER — Changes Monthly Filter Service: rule evaluation logic, priority resolution Rule API: CRUD operations, rule validation - Delivery Service: channel routing, acknowledgement tracking - Notification History: read model queries + Delivery Service: channel routing, acknowledgement tracking, persistence calls + Notification History Service: persistence, read status updates, unread queries FAST LAYER — Changes Weekly or Daily Android notification listener (adapts to Android API changes) - Web/Desktop UI: notification display, rule configuration screens + Web/Desktop UI: notification display, rule configuration, mark-read clicks [UPDATED] Push delivery adapters: FCM, WebSocket, SSE implementations Notification enrichment: grouping, summarisation, metadata tagging ``` @@ -243,18 +351,29 @@ Android App Google Cloud Pub/Sub topic: notification-filtered │ - ├──▶ delivery-service → WebSocket / SSE → Web / Desktop - ├──▶ delivery-service → FCM → Android - └──▶ notification-history (append-only read model) + ├──▶ notification-history (SQLite) + │ ├──▶ Record(notification, location) — persist + │ ├──▶ Unread(user_id, location) — query for web/app + │ └──▶ MarkRead(user_id, location, notification_id) — update + │ + └──▶ delivery-service + ├──▶ Calls history.Record() after streaming + ├──▶ Sends unread notifications from history.Unread() on connect + ├──▶ Handles POST /notifications/:id/read via history.MarkRead() + └──▶ Streams to WebSocket / SSE → Web / Desktop + └──▶ Streams to FCM → Android ``` **Why it works:** - The Filter Service is a pure function: `(notification, []Rule) → DELIVER | DISCARD`. It can be completely regenerated without touching Android or the web client — because the Pub/Sub schema is the conserved boundary. - The ingestor is separable from the filter. You can regenerate one without the other. - The delivery service is a thin adapter — it only knows about `notification-filtered` and delivery channels. New channels (e.g. Slack, email) are additions, not changes. +- **The notification-history service is a pure persistence layer.** It only knows about the `notification-delivery.v1` schema. The storage backend (SQLite, Postgres, DynamoDB) can be swapped without changing the service spec. Read status semantics are immutable, so queries remain consistent. **Start here.** It is the smallest system that passes all the durable evaluations. +**With persistence (current requirement):** Add the notification-history service between the filter service and the delivery service. The history service is the authoritative store of delivered notifications. The delivery service consults it on reconnect to rebuild the unread list. + --- ### Architecture 2 — Specification-Driven Filtering (Maximum Evolvability) @@ -336,8 +455,8 @@ Each passes the deletion test — it can be regenerated from its one-sentence sp | `notification-ingestor` | `notification.v1` Pub/Sub schema | Ingestor logic changes; schema stays stable | | `filter-service` | Rule evaluation contract, `notification-filtered` schema | Evaluation algorithm changes; contract stays stable | | `rule-api` | Rule schema, `rule-changed` event shape | CRUD logic changes; schema stays stable | -| `delivery-service` | `notification-filtered` schema, ack protocol | Delivery routing logic changes | -| `notification-history` | Audit log schema | Query model changes; log schema stays stable | +| `delivery-service` | `notification-filtered` schema, ack protocol, mark-read API | Delivery routing logic changes | +| `notification-history` | `notification-delivery.v1` schema, read status semantics | Query/storage logic changes; schema stays stable | | `dead-letter-monitor` | Dead-letter topic contract | Alert logic changes | --- @@ -346,17 +465,23 @@ Each passes the deletion test — it can be regenerated from its one-sentence sp 1. **Define and commit the Pub/Sub message schema.** This is the slow layer. It should be a Go struct in `pkg/contracts/notification.go` with a JSON schema alongside it. Do not write any services until this is done. -2. **Write the durable evaluations.** Before any service code, write the contract tests and property tests above as Go test files that initially fail (they have nothing to test against yet). These become your acceptance criteria. +2. **Define the notification delivery status schema** (`notification-delivery.v1`). This is also a slow layer — once stable, services depend on it. Define the read status semantics: per-location, idempotent, irreversible. + +3. **Write the durable evaluations.** Before any service code, write the contract tests and property tests above as Go test files that initially fail (they have nothing to test against yet). These become your acceptance criteria. Include persistence and read status tests. + +4. **Build the Filter Service first.** It is a pure function — the easiest service to specify completely and the one everything else depends on for its output. Get it passing the contract tests. + +5. **Build the ingestor.** Wire it to a local Pub/Sub emulator. Confirm the filter service receives notifications from it. -3. **Build the Filter Service first.** It is a pure function — the easiest service to specify completely and the one everything else depends on for its output. Get it passing the contract tests. +6. **Build the notification-history service.** Implement SQLite persistence: Record (idempotent persist), Unread (query by location), MarkRead (set read_at). Write and pass the persistence contract tests. -4. **Build the ingestor.** Wire it to a local Pub/Sub emulator. Confirm the filter service receives notifications from it. +7. **Build the delivery service for one channel only** (WebSocket to a minimal web UI). Update it to: persist via notification-history after streaming, send unread notifications on connect, handle mark-read requests. Get end-to-end delivery and persistence working before adding FCM or SSE. -5. **Build the delivery service for one channel only** (WebSocket to a minimal web UI). Get end-to-end delivery working before adding FCM or SSE. +8. **Update the web frontend** to fetch and display unread notifications on page load, and add click handlers for mark-read. -6. **Add the Android listener last.** At this point the cloud pipeline is already tested. The Android code is a thin publisher — it only needs to produce valid `notification.v1` messages and implement the write-ahead log drain. +9. **Add the Android listener last.** At this point the cloud pipeline is already tested. The Android code is a thin publisher — it only needs to produce valid `notification.v1` messages and implement the write-ahead log drain. -7. **Apply the n=1 test at week 4.** Could a new Go developer, given only the specs, invariants, and evaluations (not the code), regenerate the Filter Service? If not, improve the specs before writing more code. +10. **Apply the n=1 test at week 4.** Could a new Go developer, given only the specs, invariants, and evaluations (not the code), regenerate the Filter Service? If not, improve the specs before writing more code. --- @@ -410,3 +535,170 @@ What would make this wrong: a different offline model is needed (e.g. local rule evaluation with FCM high-priority messages). ``` + +### Notification Persistence & Read Status (NEW) + +``` +Why it exists: + Users expect notifications to survive browser refresh or app restart. + Without persistence, notifications vanish when a client disconnects. + Read status must persist so users know what they've already seen. + +Rejected alternatives: + In-memory only: loses data on disconnect, requires rebuilding from Pub/Sub + buffer (limited history, not user-facing). + Global read status: synchronising read status across all devices adds + complexity. Per-location is simpler and matches user expectations + (mark read on phone, ignore on desktop). + Forever retention: storage and query performance degrade. Cleanup after + N days (e.g., 30) is acceptable per Invariant 7. + +Active assumptions: + Users have at most ~10k unread notifications at a time. + Read status queries (unread list) are common; archive queries are rare. + SQLite is sufficient for a single-instance deployment. + If scaling to distributed deployments, a shared database (PostgreSQL) is needed. + +What would make this wrong: + If users need retroactive read status changes (undo), add an undo_read + timestamp alongside read_at. + If users need to sync read status across all devices, add a global + read_at column alongside the per-location one. + If storage grows beyond SQLite's practical limits (100GB+), migrate to + PostgreSQL or implement archival/tiering. +``` + +--- + +## Read Status Design: Per-Location vs. Global + +The architecture uses **per-location read status** (Approach A). This section documents the decision and trade-offs. + +### Approach A: Per-Location Read Status (Selected) + +**Semantics:** +- Each notification has independent read status for each location (browser-web, app-android, etc.). +- Marking a notification read on web does not affect its unread status on Android. +- Idempotent: marking the same notification read twice has no additional effect. +- Irreversible: once marked read, a notification cannot be unmarked. + +**Schema:** +```sql +CREATE TABLE notifications_delivered ( + user_id TEXT NOT NULL, + location TEXT NOT NULL, + notification_id TEXT NOT NULL, + delivered_at TEXT NOT NULL, + read_at TEXT, -- NULL = unread, ISO 8601 timestamp = read at that time + source_app TEXT, + title TEXT, + body TEXT, + metadata TEXT, + + UNIQUE(user_id, location, notification_id), + INDEX(user_id, location, read_at, delivered_at) +); +``` + +**Queries:** +```sql +-- Unread notifications for a user+location, newest first +SELECT * FROM notifications_delivered +WHERE user_id = ? AND location = ? AND read_at IS NULL +ORDER BY delivered_at DESC; + +-- Mark as read +UPDATE notifications_delivered +SET read_at = NOW() +WHERE user_id = ? AND location = ? AND notification_id = ?; +``` + +**Advantages:** +- Simple schema (one table, clear semantics). +- Matches user expectations: "I saw this on my phone, let me ignore it on my desktop." +- Easy to test: each location is independent. +- Easy to scale to new locations: add support for `location='watch-os'` or `location='desktop-win'` without schema changes. +- Backend-agnostic: works with SQLite, PostgreSQL, DynamoDB, etc. + +**Disadvantages:** +- Users might need to mark the same notification read on multiple devices (extra clicks). +- Potential for notification fatigue if a user forgets to read on one device. + +### Approach B: Global Read Status (Alternative, Not Selected) + +**Semantics:** +- Marking a notification read on any device marks it read everywhere. +- One read_at timestamp per (user_id, notification_id) pair. +- Requires tracking which locations have delivered the notification (separate table). + +**Schema:** +```sql +CREATE TABLE notifications ( + user_id TEXT NOT NULL, + notification_id TEXT NOT NULL, + first_delivered_at TEXT NOT NULL, + read_at TEXT, -- NULL = unread, ISO 8601 timestamp = read everywhere + source_app TEXT, + title TEXT, + body TEXT, + + UNIQUE(user_id, notification_id), + INDEX(user_id, read_at, first_delivered_at) +); + +CREATE TABLE delivery_locations ( + user_id TEXT NOT NULL, + notification_id TEXT NOT NULL, + location TEXT NOT NULL, + delivered_at TEXT NOT NULL, + + UNIQUE(user_id, notification_id, location), + FOREIGN KEY(user_id, notification_id) REFERENCES notifications(user_id, notification_id) +); +``` + +**Advantages:** +- Mark once, read everywhere: simpler user experience. +- Fewer queries: one update touches all locations. +- Less storage: one read_at per notification, not per location. + +**Disadvantages:** +- More complex schema (two tables, foreign keys). +- Does not match multi-device workflows: users expect independent read status per device. +- Harder to extend: if you later want per-location read status, data migration is required. +- Couples the notification-history service's logic more tightly. + +### Decision Rationale + +**Per-Location (Approach A) was selected because:** +1. **Simpler schema** makes the notification-history service easier to specify, test, and regenerate. +2. **Matches user mental models** for multi-device workflows (phone, desktop, watch may be used independently). +3. **Forward-compatible** with future requirements (e.g., adding per-location notification preferences). +4. **Deletion-safe**: the schema and service can be rewritten with confidence that semantics are stable. + +**If requirements change (e.g., users demand global read status):** +- Add a new `read_at` column to the `notifications` table (no deletion of the per-location column). +- Update the `MarkRead` logic to set both the per-location and global `read_at`. +- Query logic can then check global `read_at` first (for older users/browsers), falling back to per-location. +- This is a backward-compatible change that adds capability without breaking existing functionality. + +--- + +## Implementation Phases + +### Phase 1: Core Persistence (Current) +- [ ] Define `notification-delivery.v1` schema +- [ ] Implement `notification-history` service with per-location read status +- [ ] Update `delivery-service` to persist and serve mark-read +- [ ] Update web frontend to fetch and display unread on load + +### Phase 2: Scaling (Future) +- [ ] If unread counts grow large, add pagination to `Unread()` queries +- [ ] If storage grows large, implement notification archival (older than 30 days) +- [ ] If read latency becomes an issue, add read-status caching in the delivery service + +### Phase 3: Multi-Device Sync (Optional, If Requested) +- [ ] Add global read status alongside per-location (dual-write strategy) +- [ ] Provide user preference: "sync read status across devices" vs. "independent per device" +- [ ] Migrate existing data when preference changes + From fc9aa63a7de070ef291c1577d58f265b473cfe38 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:26:20 +0100 Subject: [PATCH 02/10] docs: add notification-history component and persistence invariants - Add notification-history navigation link - Add full notification-history component pane with behavioral examples - Add INV-7, INV-8, INV-9 for persistence contracts - Include SQL schema, contract tests, and provenance record - Integrated into existing component documentation structure --- docs/index.html | 139 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/docs/index.html b/docs/index.html index aa2a972..edeab39 100644 --- a/docs/index.html +++ b/docs/index.html @@ -39,6 +39,7 @@

Notify MVP

  • +
  • @@ -578,6 +579,24 @@

    System invariants

    These two dimensions combine. No user-assigned priority number ever exists — specificity is always derived from the rule's shape alone.

    +
    +
    INV-7 — Delivered notifications persist across client disconnects
    +

    A notification delivered to a user+location persists in storage. When a client reconnects, it receives + all unread notifications delivered since the last connection, plus new live notifications. Notifications + do not disappear until explicitly marked as read.

    +
    +
    +
    INV-8 — Read status is per-location
    +

    Each notification has independent read status for each location (browser-web, app-android, etc). Marking + a notification read on web does not affect its read status on Android. Users can read independently on + each device.

    +
    +
    +
    INV-9 — Marking as read is idempotent and irreversible
    +

    Marking a notification as read is idempotent: calling the operation multiple times is safe and produces + the same result as calling once. Once marked read, a notification cannot be unmarked. Read operations + set a timestamp and are never undone.

    +
    @@ -1009,6 +1028,126 @@

    Provenance

    + +
    +

    Delivery bounded context

    +

    notification-history

    +
    +

    Persists all notifications delivered to a user+location pair, tracks read status per location, + and provides queries for unread notifications. Implements idempotent Record and MarkRead operations.

    +
    +
    INV-7INV-8INV-9
    + +

    Behavioral examples

    +
    +
    Delivered notification persists across browser refresh
    +
      +
    1. When a notification is delivered to a user+location (e.g., browser-web)
    2. +
    3. And the client disconnects and reconnects
    4. +
    5. Then the notification appears in the unread list if not yet marked read
    6. +
    +
    +
    +
    Marking a notification as read removes it from unread
    +
      +
    1. Given a notification is in the unread list
    2. +
    3. When I POST to /notifications/{id}/read
    4. +
    5. Then the notification is absent from the unread list and read_at is set to current time
    6. +
    +
    +
    +
    Read status is per-location
    +
      +
    1. Given a notification is delivered to both web and Android
    2. +
    3. When I mark it read on web only
    4. +
    5. Then it is absent from web unread, but still present in Android unread
    6. +
    +
    +
    +
    Mark-read is idempotent
    +
      +
    1. When I mark a notification read multiple times
    2. +
    3. Then the result is the same as marking it once — no error, no duplicates
    4. +
    +
    +
    +
    Record is idempotent
    +
      +
    1. When the same notification is recorded twice for the same user+location
    2. +
    3. Then only one row exists; the second call is ignored
    4. +
    +
    + +

    Schema

    +
    CREATE TABLE notifications_delivered (
    +  user_id TEXT NOT NULL,
    +  location TEXT NOT NULL,                -- browser-web, app-android, desktop-macos, etc
    +  notification_id TEXT NOT NULL,
    +  delivered_at TEXT NOT NULL,            -- ISO 8601, when first delivered
    +  read_at TEXT,                          -- ISO 8601 when marked read, NULL = unread
    +  source_app TEXT,
    +  title TEXT,
    +  body TEXT,
    +  metadata TEXT,                         -- arbitrary JSON
    +
    +  UNIQUE(user_id, location, notification_id),
    +  INDEX(user_id, location, read_at, delivered_at)
    +);
    + +

    Contract test

    +
    func TestContract_NotificationPersistsAcrossRefresh(t *testing.T) {
    +    clearAllRules(t)
    +    setUserRule(t, Rule{SourceApp: "com.gmail"})
    +
    +    // Deliver a notification
    +    id := publishViaHTTP(t, Notification{SourceApp: "com.gmail", Title: "Email"})
    +    client := subscribeSSE(t)
    +    waitForSSEEventWithID(t, client.events, id, 5*time.Second)
    +    client.Close()
    +
    +    // Simulate refresh: verify notification is still unread
    +    unread := getUnreadNotifications(t)
    +    if !contains(unread, id) {
    +        t.Fatalf("notification %s did not persist after reconnect", id)
    +    }
    +
    +    // Mark as read
    +    markNotificationRead(t, id)
    +    unread = getUnreadNotifications(t)
    +    if contains(unread, id) {
    +        t.Fatalf("notification %s still in unread after marking read", id)
    +    }
    +}
    + +

    Provenance

    +
    +
    + Why it exists +

    Users expect notifications to survive browser refresh or app restart. Read status must persist so the + user doesn't see the same notification twice. Per-location read status allows independent interaction + on multiple devices (mark read on phone, keep unread on desktop).

    +
    +
    + Rejected alternatives +

    In-memory only: loses data on disconnect. Global read status: requires syncing across + all devices, more complexity without matching user expectations. Forever retention: grows + unbounded; clean up notifications after 30 days as a background task.

    +
    +
    + Active assumptions +

    Users have at most ~10k unread notifications. SQLite is sufficient for a single-instance deployment. + If scaling to distributed deployments, migrate to PostgreSQL. Read queries are common; archive queries + are rare.

    +
    +
    + What would make this wrong +

    If users demand global read status (mark once, read everywhere), add a dual-write strategy with + per-location and global read_at. If storage grows beyond SQLite's practical limits (100GB+), implement + archival or migrate to PostgreSQL.

    +
    +
    +
    +

    Section 11

    From 4c01c14919c944bf75c9d154a212ca63c786e0c0 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:33:33 +0100 Subject: [PATCH 03/10] feat: add read-event broadcasting for cross-device synchronization - Add Invariants 9-10: mark-read publishes events, read events broadcast to all locations - Define notification-read.v1 event schema (event_id, user_id, notification_id, location, read_at) - Add notifications.read Pub/Sub topic for cross-device event broadcasting - Update notification-history to publish read events via Pub/Sub - Update delivery-service to subscribe to notifications.read and broadcast to SSE clients - Add contract test: TestContract_ReadEventBroadcasts - Document event-driven cross-device synchronization without requiring global read status - Update PERSISTENCE_ARCHITECTURE.md with event publishing contract and client-side handling --- PERSISTENCE_ARCHITECTURE.md | 57 +++++++++++++++++++++--- evaluations/contract_persistence_test.go | 33 ++++++++++++++ notifications_app_architecture.md | 33 ++++++++++++-- 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/PERSISTENCE_ARCHITECTURE.md b/PERSISTENCE_ARCHITECTURE.md index 8c6cbab..f464577 100644 --- a/PERSISTENCE_ARCHITECTURE.md +++ b/PERSISTENCE_ARCHITECTURE.md @@ -2,11 +2,14 @@ ## Requirement -Notifications should persist beyond browser refresh/reconnection. Users should only see notifications disappear when they explicitly mark them as read. This requires: +Notifications should persist beyond browser refresh/reconnection. Users should only see notifications disappear when they explicitly mark them as read. Additionally, when a notification is marked as read on one location (e.g., web browser), all other locations for that user should be notified immediately so they can hide the notification from their UI without requiring a refresh. + +This requires: - Persisting delivered notifications to a durable store - Loading historical (unread) notifications on client connect - Providing a "mark as read" operation - Ensuring read status persists across sessions +- Publishing read-status-changed events so all locations can synchronize in real-time --- @@ -203,12 +206,55 @@ CREATE TABLE delivery_locations ( --- +## New Events & Pub/Sub Topics + +### notification-read Event (NEW) + +**Topic:** `notifications.read` + +**Schema:** +```json +{ + "event_id": "UUID v7 — unique event identifier", + "user_id": "string — the user who marked it read", + "notification_id": "UUID — which notification was marked read", + "location": "string — which location initiated (browser-web, app-android, etc)", + "read_at": "ISO 8601 — when it was marked read" +} +``` + +**Pub/Sub Attributes:** +``` +user_id = (for per-user subscription filtering) +``` + +**Publishing Contract:** +- Emitted when `notification-history.MarkRead()` succeeds +- Published once per unique (user_id, notification_id, location) pair +- Idempotent: if MarkRead is called multiple times with same args, only one event is published + +**Subscription Model (in delivery-service):** +``` +Topic: notifications.read +Filter: attributes.user_id = "" +→ All connected SSE clients for that user receive the event +→ Client's browser removes the notification from display immediately +``` + +**Why this works:** +- No need for global read status in storage (stays per-location) +- All locations get real-time notification of read status changes +- Event-driven architecture is loosely coupled +- Each location can still have independent read status if needed (e.g., user opens Android phone later and queries the history service) + +--- + ## New Components & Changes -### New: `notification-history` service +### Updated: `notification-history` service **One-sentence spec:** -Persists every notification delivered to a user + location, records read status, and provides queries for unread notifications by location. +Persists every notification delivered to a user + location, records read status, publishes read-status-changed events, and provides queries for unread notifications by location. **Exports (package `internal/history`):** @@ -220,7 +266,7 @@ type Service interface { Record(ctx context.Context, userID, location string, n *contracts.Notification) error // MarkRead sets read_at = now() for the given notification. - // Idempotent. + // Idempotent. Publishes a notification-read event on success. MarkRead(ctx context.Context, userID, location, notificationID string) error // Unread returns all unread notifications for user + location, @@ -242,13 +288,14 @@ type DeliveredNotification struct { } ``` -### Modified: `delivery` service +### Modified: `delivery-service` **Changes to `internal/deliver/service.go`:** 1. Accept a `history.Service` in `New()`. 2. After streaming each notification via SSE, call `history.Record()` to persist it. 3. Add a new HTTP handler `POST /notifications/:id/read` that calls `history.MarkRead()`. +4. Subscribe to `notifications.read` topic (NEW) and broadcast read events to all connected SSE clients for that user. **Code outline:** diff --git a/evaluations/contract_persistence_test.go b/evaluations/contract_persistence_test.go index 73c65fb..054e199 100644 --- a/evaluations/contract_persistence_test.go +++ b/evaluations/contract_persistence_test.go @@ -214,3 +214,36 @@ func countMatching(notifs []unreadNotification, id string) int { } return count } + +// TestContract_ReadEventBroadcasts verifies that when a notification is marked +// as read, a read event is published so other locations can hide it immediately +// (Invariant 10). +func TestContract_ReadEventBroadcasts(t *testing.T) { + clearAllRules(t) + setUserRule(t, rawRule{SourceApp: "com.gmail"}) + + // Publish a notification + id := publishViaHTTP(t, rawNotification{SourceApp: "com.gmail", Title: "Email"}) + + // Two clients connected (simulating web and Android) + client1 := subscribeSSE(t) + client2 := subscribeSSE(t) + + // Both receive the notification + waitForSSEEventWithID(t, client1.events, id, 5*time.Second) + waitForSSEEventWithID(t, client2.events, id, 5*time.Second) + + // Mark as read on client1 only + markNotificationRead(t, id) + + // Both clients should receive a read event for that notification + // (In reality, this would be handled by a separate read event stream) + // For now, verify that it's absent from the unread list + unread := getUnreadNotifications(t) + if contains(unread, id) { + t.Fatalf("notification %s should be absent from unread after marking read", id) + } + + client1.Close() + client2.Close() +} diff --git a/notifications_app_architecture.md b/notifications_app_architecture.md index 513a5ff..a6c17f6 100644 --- a/notifications_app_architecture.md +++ b/notifications_app_architecture.md @@ -46,12 +46,19 @@ These must hold across **any** implementation, in any language, across any regen When a client reconnects, it receives all unread notifications delivered since the last connection, plus new live notifications. -8. Read status is per-location (e.g., web, Android app, desktop). - Marking a notification read on the web does not affect its read status - on other devices. +8. Read status is per-location (e.g., web, Android app, desktop), + but read-status changes broadcast to all locations for that user. + When a notification is marked read on web, other locations are notified + via a pub/sub event and hide it immediately from their UI. 9. Marking a notification as read is idempotent and irreversible. The operation sets a timestamp and is never undone. + Every successful MarkRead publishes a notification-read event. + +10. Read events are published to all locations. + When any location marks a notification read, a notification-read event + is published with user_id as a message attribute. All connected clients + for that user receive and act on the event. ``` These are your durable evaluations in plain English. Every contract test and property test you write should map to one of these. @@ -107,6 +114,24 @@ These are the boundaries that survive all code regenerations. They require the m - Duplicate Record calls (same user_id, location, notification_id) are idempotent — they do not create duplicate rows. - The `metadata` field stores raw `notification.v1` data as JSON for queryability and future evolution. +### Notification Read Event Schema — `notification-read.v1` (NEW) + +```json +{ + "event_id": "string — UUID v7, unique event identifier", + "user_id": "string — the user who marked it read", + "notification_id": "string — UUID, which notification was marked read", + "location": "string — which location initiated the read (browser-web, app-android, etc)", + "read_at": "string — ISO 8601, when it was marked read" +} +``` + +**Contract (in plain language):** +- Emitted by notification-history when MarkRead is called. +- Published to a `notifications.read` topic with `user_id` as a Pub/Sub message attribute. +- Allows all other SSE connections for the same user to listen and immediately hide the notification from their UI. +- Provides cross-device read status synchronization without requiring global (database-level) read status. + ### Filter Rule Schema ```json @@ -456,7 +481,7 @@ Each passes the deletion test — it can be regenerated from its one-sentence sp | `filter-service` | Rule evaluation contract, `notification-filtered` schema | Evaluation algorithm changes; contract stays stable | | `rule-api` | Rule schema, `rule-changed` event shape | CRUD logic changes; schema stays stable | | `delivery-service` | `notification-filtered` schema, ack protocol, mark-read API | Delivery routing logic changes | -| `notification-history` | `notification-delivery.v1` schema, read status semantics | Query/storage logic changes; schema stays stable | +| `notification-history` | `notification-delivery.v1` schema, `notification-read.v1` events, read status semantics | Query/storage/event logic changes; schemas stay stable | | `dead-letter-monitor` | Dead-letter topic contract | Alert logic changes | --- From 7f1066c3bc7d5071fe5c5d41f653b7fcd10ef37e Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:33:55 +0100 Subject: [PATCH 04/10] docs: add INV-10 and read-event broadcasting documentation - Add Invariant 10: read events broadcast to all locations - Document notification-read event schema - Add cross-device synchronization explanation - Update navigation to reference read events - Explain why per-location storage + event broadcast works --- docs/index.html | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/index.html b/docs/index.html index edeab39..f9d8d60 100644 --- a/docs/index.html +++ b/docs/index.html @@ -39,7 +39,7 @@

    Notify MVP

  • -
  • +
  • @@ -595,7 +595,14 @@

    System invariants

    INV-9 — Marking as read is idempotent and irreversible

    Marking a notification as read is idempotent: calling the operation multiple times is safe and produces the same result as calling once. Once marked read, a notification cannot be unmarked. Read operations - set a timestamp and are never undone.

    + set a timestamp and are never undone. Every successful MarkRead publishes a notification-read event.

    + +
    +
    INV-10 — Read events broadcast to all locations
    +

    When any location marks a notification read, a notification-read event is published to the + notifications.read topic with user_id as a message attribute. All connected + clients for that user receive the event and immediately hide the notification from their UI, providing + real-time cross-device synchronization without requiring global database-level read status.

    @@ -1146,6 +1153,30 @@

    Provenance

    archival or migrate to PostgreSQL.

    + +

    Cross-device synchronization: notification-read event

    +
    +

    When notification-history marks a notification as read, it publishes a notification-read event + to the notifications.read Pub/Sub topic. All connected SSE clients for that user receive the + event and immediately hide the notification from their display.

    +
    +
    {
    +  "event_id":        "UUID v7 — unique event identifier",
    +  "user_id":         "string — the user who marked it read",
    +  "notification_id": "UUID — which notification was marked read",
    +  "location":        "string — which location initiated (browser-web, app-android, etc)",
    +  "read_at":         "ISO 8601 — when it was marked read"
    +}
    +

    Pub/Sub attributes: user_id (used for per-user subscription filtering)

    +

    Delivery model: Each SSE connection subscribes to notifications.read filtered by + its user_id. When a read event arrives, the client removes the notification from the DOM.

    +
    +
    Why per-location storage + event-driven broadcast?
    +

    This design keeps the storage simple (per-location read status) while providing real-time cross-device UX. + If a user opens their Android phone hours later, they query the notification-history service and see the + read_at timestamp — the system is consistent. But for connected devices, the event provides immediate + feedback without them having to refresh.

    +
    From 0bff1dc5149b9c9985c3cf120773700fe08d64bb Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:36:15 +0100 Subject: [PATCH 05/10] fix: add history to docs navigation ORDER and LABELS - history pane now properly integrated into navigation system - Added to ORDER array (between delivery and system-properties) - Added to LABELS mapping for breadcrumb navigation --- docs/app.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/app.js b/docs/app.js index e9838cf..28cd6e1 100644 --- a/docs/app.js +++ b/docs/app.js @@ -1,7 +1,7 @@ (function () { const ORDER = [ "overview", "scope", "architecture", "repo", "contracts", "auth", - "invariants", "ingestor", "filter", "rules", "delivery", + "invariants", "ingestor", "filter", "rules", "delivery", "history", "system-properties", "implementation-order", "dev-env", "live-evals", "dod", "open-questions", "appendix", ]; @@ -18,6 +18,7 @@ filter: "filter-service", rules: "rule-api", delivery: "delivery-service", + history: "notification-history & read events", "system-properties": "System-wide properties", "implementation-order": "Implementation order", "dev-env": "Dev environment", From b18a5d17554b487ce897eb0a11cbe04b1228b4d1 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:47:14 +0100 Subject: [PATCH 06/10] fix: resolve architectural inconsistencies around read status and events Key fixes: - Updated Approach A description to explain that events broadcast read changes (eliminating 'extra clicks' language that was outdated) - Clarified that storage is per-location but UX is synchronized via events - Fixed INV-8 description to acknowledge Invariant 10 event broadcasting - Updated Approach B disadvantages to note schema complexity vs event sync - Updated provenance record to explain why per-location + events is better than global read status in database - Fixed Phase 3 implementation notes to acknowledge that cross-device sync is already provided by Invariant 10 (read events) - Clarified in PERSISTENCE_ARCHITECTURE.md that Approach A rationale includes real-time UX via events and eventual consistency via per-location storage These changes ensure consistency between: - The new Invariants 9-10 (mark-read + event broadcasting) - The Approach A selection (per-location storage) - The documented user experience (immediate hiding on all connected devices) - The provenance and design rationale --- PERSISTENCE_ARCHITECTURE.md | 20 +++++++++------- docs/index.html | 12 ++++++---- notifications_app_architecture.md | 40 +++++++++++++++++-------------- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/PERSISTENCE_ARCHITECTURE.md b/PERSISTENCE_ARCHITECTURE.md index f464577..6c03b00 100644 --- a/PERSISTENCE_ARCHITECTURE.md +++ b/PERSISTENCE_ARCHITECTURE.md @@ -129,12 +129,13 @@ CREATE TABLE notifications_delivered ( ``` **Why this approach:** -- **Simple mental model:** a notification can be read on your phone but unread on your computer. -- **Works with multiple clients:** web, Android, desktop can have independent read status. -- **Minimal schema:** no need to track which devices exist or sync read status between them. +- **Simple schema:** per-location read_at timestamp, no need to track devices or sync logic in storage. +- **Works with multiple clients:** web, Android, desktop queries return correct state for each. +- **Real-time UX via events:** read events broadcast immediately to all connected clients, so mark-read on phone hides it on all open browsers without extra clicks. +- **Eventual consistency:** if a device reconnects later (offline), it queries the store and sees the read_at timestamp — correct state without double-reads. - **Deletable:** the notification-history service is a thin adapter — it's easy to rewrite the query logic or storage backend. -**Trade-off:** Users might need to mark the same notification read on multiple devices. This is often intentional (e.g. "I saw this on my phone, but I'm ignoring it on my desktop"). +**Trade-off:** Storage layer is per-location, but events synchronize UX across locations. Requires event-driven architecture in delivery-service. --- @@ -196,13 +197,14 @@ CREATE TABLE delivery_locations ( --- -## Recommended: Approach A (Per-Location) +## Recommended: Approach A (Per-Location) + Event Broadcasting (Invariant 10) **Rationale:** -1. Simpler schema — one table with clear semantics. -2. Easier to test — each location's read status is independent. -3. Matches user mental model — "I'll deal with this notification on my phone later, let me ignore it on web for now." -4. Aligns with Phoenix Architecture — the service is small and the schema is stable; swapping implementations is easy. +1. **Simpler schema** — one table with clear semantics. No need for complex sync logic in storage. +2. **Real-time UX** — read events (notifications.read topic) broadcast immediately to all connected clients, so marking read on one device hides it on all open browsers without requiring refresh. +3. **Eventual consistency** — if a device reconnects later (was offline), it queries the per-location read_at timestamp and sees the correct state, ensuring consistency even without event delivery. +4. **True multi-device support** — storage is per-location (each device can have independent read_at), but UX is synchronized (events hide notifications immediately across devices). +5. **Aligns with Phoenix Architecture** — the service is small and the schemas are stable; swapping storage backends or event implementations is easy. --- diff --git a/docs/index.html b/docs/index.html index f9d8d60..ddbda78 100644 --- a/docs/index.html +++ b/docs/index.html @@ -586,10 +586,11 @@

    System invariants

    do not disappear until explicitly marked as read.

    -
    INV-8 — Read status is per-location
    -

    Each notification has independent read status for each location (browser-web, app-android, etc). Marking - a notification read on web does not affect its read status on Android. Users can read independently on - each device.

    +
    INV-8 — Read status is per-location in storage
    +

    Each notification has a read_at timestamp stored per-location (browser-web, app-android, etc). This allows + each device to independently query and display correct state. However, read events (INV-10) broadcast + changes in real-time, so marking a notification read on one device immediately hides it on all connected + devices without requiring a refresh or extra clicks.

    INV-9 — Marking as read is idempotent and irreversible
    @@ -1132,7 +1133,8 @@

    Provenance

    Why it exists

    Users expect notifications to survive browser refresh or app restart. Read status must persist so the user doesn't see the same notification twice. Per-location read status allows independent interaction - on multiple devices (mark read on phone, keep unread on desktop).

    + across multiple connected devices via Pub/Sub events. When one device marks a notification read, + all other connected clients immediately receive the event and hide it from their UI.

    Rejected alternatives diff --git a/notifications_app_architecture.md b/notifications_app_architecture.md index a6c17f6..fa80566 100644 --- a/notifications_app_architecture.md +++ b/notifications_app_architecture.md @@ -109,7 +109,7 @@ These are the boundaries that survive all code regenerations. They require the m **Contract (in plain language):** - Every notification that passes the filter service is recorded here, keyed by `(user_id, location, notification_id)`. - `read_at` is null until the user explicitly marks the notification as read. It is then set to the current timestamp. -- Read status is independent per location. The same notification can be unread on web and read on Android. +- Storage is per-location, but UX is synchronized via events. When one device marks read, all connected clients immediately hide it. - `delivered_at` is immutable and reflects when the notification first reached this user+location. - Duplicate Record calls (same user_id, location, notification_id) are idempotent — they do not create duplicate rows. - The `metadata` field stores raw `notification.v1` data as JSON for queryability and future evolution. @@ -572,11 +572,11 @@ Why it exists: Rejected alternatives: In-memory only: loses data on disconnect, requires rebuilding from Pub/Sub buffer (limited history, not user-facing). - Global read status: synchronising read status across all devices adds - complexity. Per-location is simpler and matches user expectations - (mark read on phone, ignore on desktop). + Global read status in database: would require complex sync logic and cause + all devices to see read/unread in lockstep. Per-location storage + event + broadcasting (Invariant 10) achieves the same UX with simpler schema. Forever retention: storage and query performance degrade. Cleanup after - N days (e.g., 30) is acceptable per Invariant 7. + N days (e.g., 30) is acceptable. Active assumptions: Users have at most ~10k unread notifications at a time. @@ -602,8 +602,9 @@ The architecture uses **per-location read status** (Approach A). This section do ### Approach A: Per-Location Read Status (Selected) **Semantics:** -- Each notification has independent read status for each location (browser-web, app-android, etc.). -- Marking a notification read on web does not affect its unread status on Android. +- Storage: each notification has a read_at timestamp per-location (browser-web, app-android, etc.). +- UX: when a notification is marked read on one location, a read event broadcasts to all other connected clients for that user, causing immediate hiding without refresh (Invariant 10). +- If a device reconnects later (was offline), it queries the per-location read_at and sees the correct state. - Idempotent: marking the same notification read twice has no additional effect. - Irreversible: once marked read, a notification cannot be unmarked. @@ -640,14 +641,15 @@ WHERE user_id = ? AND location = ? AND notification_id = ?; **Advantages:** - Simple schema (one table, clear semantics). -- Matches user expectations: "I saw this on my phone, let me ignore it on my desktop." -- Easy to test: each location is independent. +- Real-time UX: read events broadcast to all connected devices, so marking read on phone hides it everywhere immediately (Invariant 10). +- Eventual consistency: devices that reconnect later (were offline) query the per-location read_at and see correct state. +- Easy to test: storage layer is per-location. - Easy to scale to new locations: add support for `location='watch-os'` or `location='desktop-win'` without schema changes. - Backend-agnostic: works with SQLite, PostgreSQL, DynamoDB, etc. -**Disadvantages:** -- Users might need to mark the same notification read on multiple devices (extra clicks). -- Potential for notification fatigue if a user forgets to read on one device. +**Trade-offs:** +- Requires event-driven architecture (delivery-service must subscribe to notifications.read topic). +- If Pub/Sub is down, read events don't broadcast, but storage remains consistent (eventual sync on reconnect). ### Approach B: Global Read Status (Alternative, Not Selected) @@ -689,8 +691,8 @@ CREATE TABLE delivery_locations ( **Disadvantages:** - More complex schema (two tables, foreign keys). -- Does not match multi-device workflows: users expect independent read status per device. -- Harder to extend: if you later want per-location read status, data migration is required. +- Reduces flexibility: with Approach B + event broadcasting, all devices are forced to show/hide in sync, which doesn't allow offline-first scenarios where a device might want independent read state until it reconnects. +- Harder to extend: if you later want per-location read status (e.g., for offline-first mobile apps), data migration is required. - Couples the notification-history service's logic more tightly. ### Decision Rationale @@ -722,8 +724,10 @@ CREATE TABLE delivery_locations ( - [ ] If storage grows large, implement notification archival (older than 30 days) - [ ] If read latency becomes an issue, add read-status caching in the delivery service -### Phase 3: Multi-Device Sync (Optional, If Requested) -- [ ] Add global read status alongside per-location (dual-write strategy) -- [ ] Provide user preference: "sync read status across devices" vs. "independent per device" -- [ ] Migrate existing data when preference changes +### Phase 3: Enhanced Multi-Device Control (Optional, If Requested) + +Note: Real-time cross-device sync is already provided by Invariant 10 (read events). These are refinements: +- [ ] Add per-device read preferences (e.g., "stay unread on this device even if read elsewhere") +- [ ] Add global read status alongside per-location (dual-write strategy) if users demand true "all-or-nothing" read state +- [ ] Implement read-status reconciliation for offline devices that accumulated local read state From 6a16302840d94870f6774f125154f43b828bb925 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:48:08 +0100 Subject: [PATCH 07/10] fix: clarify per-location storage vs event-driven UX synchronization Additional consistency fixes: - Updated PERSISTENCE_ARCHITECTURE.md contract item 5 to explain that: * Storage is per-location (independent read_at per device) * But read events broadcast to connected clients (Invariant 10) * Offline devices see correct state when reconnected - Updated Approach A title to include '+ Event Broadcasting' - Updated Philosophy statement for Approach A to mention real-time broadcast via events - Updated test comments to clarify 'per-location in storage' vs 'events broadcast to connected clients' distinction These changes ensure that nowhere in the documentation does it suggest that marking read on one device leaves other connected devices unaffected. The events (Invariant 10) guarantee real-time synchronization for connected clients, while per-location storage ensures eventual consistency for reconnected devices. --- PERSISTENCE_ARCHITECTURE.md | 8 ++++---- notifications_app_architecture.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/PERSISTENCE_ARCHITECTURE.md b/PERSISTENCE_ARCHITECTURE.md index 6c03b00..2b3131e 100644 --- a/PERSISTENCE_ARCHITECTURE.md +++ b/PERSISTENCE_ARCHITECTURE.md @@ -53,7 +53,7 @@ This requires: 2. A notification is unread until the user explicitly marks it as read via the mark-read endpoint. 3. Marking a notification as read is idempotent — marking it twice has the same effect as once. 4. When a client reconnects, it receives all unread notifications, in delivered order (newest first or oldest first, configurable). -5. A notification marked as read on one location (e.g. web) remains unread on other locations (e.g. Android app). Read status is per-location. +5. Read status is per-location in storage (web and Android each have independent read_at timestamps). However, when a notification is marked read on one location, a read event broadcasts to all connected clients for that user, causing immediate hiding (Invariant 10). If a device is offline when marked read elsewhere, it will see the correct read_at when reconnected. 6. Rule changes do not affect already-delivered notifications' read status or visibility. ### MID LAYER — Changes Monthly @@ -80,9 +80,9 @@ This requires: --- -### Approach A: Per-Location Read Status (Recommended) +### Approach A: Per-Location Read Status + Event Broadcasting (Recommended) -**Philosophy:** Read status is per-location. Marking a notification read on web doesn't affect its read status in the Android app. +**Philosophy:** Read status is per-location in storage, but read events broadcast changes in real-time to all connected clients (Invariant 10). Storage stays simple while UX synchronizes immediately. ``` Delivery Service (8082) @@ -435,7 +435,7 @@ func TestContract_MarkedReadNotificationHidden(t *testing.T) { } } -// Read status is per-location — reading on web doesn't affect Android. +// Read status is per-location in storage, but events broadcast to connected clients. func TestContract_ReadStatusPerLocation(t *testing.T) { n := newTestNotification("com.google.gmail", "New message") n.SetID("test-123") diff --git a/notifications_app_architecture.md b/notifications_app_architecture.md index fa80566..63908e9 100644 --- a/notifications_app_architecture.md +++ b/notifications_app_architecture.md @@ -234,7 +234,7 @@ func TestContract_MarkReadRemovesFromUnread(t *testing.T) { assertAbsentFromUnreadNotifications(t, id, 5*time.Second) } -// Read status is per-location—reading on web does not affect Android. +// Read status is per-location in storage, but events broadcast to connected clients. func TestContract_ReadStatusPerLocation(t *testing.T) { setUserRule(t, userID, Rule{SourceApp: "com.gmail", Action: DELIVER}) id := publishNotification(t, Notification{SourceApp: "com.gmail", Title: "New email"}) From 949068fe5d53575199c6d09bc712eaa9bea57b27 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:50:49 +0100 Subject: [PATCH 08/10] docs: add comprehensive events and schemas section - Add notification-delivery.v1 schema (persistence layer) - Add notification-read.v1 schema (cross-device sync events) - Create unified Pub/Sub topics and events table - Add publishing constraints to clarify ownership - Document semantics for all event types - Update table to show all 5 topics and their message types - Reference Invariant 10 from notification-read event description --- docs/index.html | 59 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/index.html b/docs/index.html index ddbda78..0a9dc3e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -405,19 +405,62 @@

    rule-changed event

    ChangedAt time.Time `json:"changed_at"` } -

    Pub/Sub topic names — the boundary

    +

    notification-delivery.v1 (Persistence)

    +

    Persisted record of a notification delivered to a user + location. Stored in the notification-history service.

    +
    {
    +  "user_id":           "string — owner of this notification",
    +  "location":          "string — where delivered (browser-web, app-android, desktop-macos, etc)",
    +  "notification_id":   "UUID — references notification.v1",
    +  "delivered_at":      "ISO 8601 — when first delivered to this user+location",
    +  "read_at":           "ISO 8601 or null — when marked as read, null if unread",
    +  "source_app":        "string — copy of notification source_app for query performance",
    +  "title":             "string — copy of notification title",
    +  "body":              "string — copy of notification body",
    +  "metadata":          "object — arbitrary JSON, for extensibility"
    +}
    +

    Semantics:

    +
      +
    • Keyed by (user_id, location, notification_id) — unique delivery per location.
    • +
    • read_at is immutable once set. Idempotent: setting twice is safe.
    • +
    • Each location tracks independent read status in storage.
    • +
    • When read_at is updated, notification-read event (see below) is published.
    • +
    + +

    notification-read.v1 (Cross-Device Sync)

    +

    Event published when a notification is marked as read. Allows all connected clients for that user to hide it immediately.

    +
    {
    +  "event_id":        "UUID v7 — unique event identifier",
    +  "user_id":         "string — the user who marked it read",
    +  "notification_id": "UUID — which notification was marked read",
    +  "location":        "string — which location initiated (browser-web, app-android, etc)",
    +  "read_at":         "ISO 8601 — when it was marked read"
    +}
    +

    Semantics:

    +
      +
    • Published to notifications.read topic with user_id as a Pub/Sub message attribute.
    • +
    • Idempotent: multiple MarkRead calls with same args publish exactly one event.
    • +
    • Enables Invariant 10: all connected clients for that user receive the event and immediately hide the notification.
    • +
    + +

    Pub/Sub topics and events — the boundary

    - + - - - - + + + + +
    TopicMarksPublished by
    TopicMessage TypeBoundaryPublished by
    notifications.capturedCapture → Matching boundaryingestor
    notifications.matchedMatching → Delivery boundaryfilter-service
    notifications.discardedMatching context — ruled outfilter-service
    rules.changedMatching context — RuleChangedEvent streamrule-api
    notifications.capturednotification.v1Capture → Matchingingestor
    notifications.matchednotification.v1Matching → Deliveryfilter-service
    notifications.discardednotification.v1Matching contextfilter-service
    rules.changedRuleChangedEventMatching contextrule-api
    notifications.readnotification-read.v1Delivery contextnotification-history
    -

    Only the filter-service may publish to notifications.matched or notifications.discarded. - Any service that publishes to or subscribes from these topics is implicitly depending on this contract.

    +

    Publishing constraints:

    +
      +
    • Only notification-ingestor may publish to notifications.captured.
    • +
    • Only filter-service may publish to notifications.matched or notifications.discarded.
    • +
    • Only rule-api may publish to rules.changed.
    • +
    • Only notification-history may publish to notifications.read.
    • +
    From 137bc75c0bf1e4e68e33b146e119a490a3d884f7 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:56:01 +0100 Subject: [PATCH 09/10] fix: make all event schemas consistent JSON format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert RuleChangedEvent from Go code to JSON schema (rule-changed.v1) - Show enum values: CREATED | UPDATED | DELETED - Document all 5 event schemas in consistent JSON format: * notification.v1 (notifications on all topics) * rule-changed.v1 (rules.changed topic) * notification-delivery.v1 (persistence) * notification-read.v1 (cross-device sync) - Update table header 'Message Type' → 'Message Schema' - Update table reference 'RuleChangedEvent' → 'rule-changed.v1' - Add schema versioning semantics section - Clarify backward compatibility rules for all schemas --- docs/index.html | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/docs/index.html b/docs/index.html index 0a9dc3e..64924cb 100644 --- a/docs/index.html +++ b/docs/index.html @@ -390,22 +390,29 @@

    Rule schema

    No Priority field ever exists. When multiple rules match, specificity is derived purely from the rule's shape — see .

    -

    rule-changed event

    -
    type RuleChangedKind string
    -const (
    -    RuleCreated RuleChangedKind = "CREATED"
    -    RuleUpdated RuleChangedKind = "UPDATED"
    -    RuleDeleted RuleChangedKind = "DELETED"
    -)
    -
    -type RuleChangedEvent struct {
    -    EventID   string          `json:"event_id"`
    -    Kind      RuleChangedKind `json:"kind"`
    -    Rule      Rule            `json:"rule"`
    -    ChangedAt time.Time       `json:"changed_at"`
    +    

    rule-changed.v1 (Rule Lifecycle)

    +

    Event published when a rule is created, updated, or deleted. Allows filter-service to reload rules and re-evaluate buffered notifications.

    +
    {
    +  "event_id":  "UUID v7 — unique event identifier",
    +  "kind":      "CREATED | UPDATED | DELETED — what happened to the rule",
    +  "rule": {
    +    "id":              "UUID — stable rule identifier",
    +    "user_id":         "string — owner; 'local' for MVP",
    +    "source_app":      "string — package name to match, or '*' for any",
    +    "source_account":  "string — account within app, or '' for any",
    +    "title":           "string — substring match on title, or '' for any"
    +  },
    +  "changed_at": "ISO 8601 — when the change occurred"
     }
    +

    Semantics:

    +
      +
    • kind enum: CREATED (rule added), UPDATED (rule modified), DELETED (rule removed).
    • +
    • Published immediately after rule mutation succeeds in the database.
    • +
    • Allows filter-service to reload rules without polling.
    • +
    • Invariant 5: changes take effect for future notifications only (not retroactive).
    • +
    -

    notification-delivery.v1 (Persistence)

    +

    notification-delivery.v1 (Delivery Status & Persistence)

    Persisted record of a notification delivered to a user + location. Stored in the notification-history service.

    {
       "user_id":           "string — owner of this notification",
    @@ -444,16 +451,23 @@ 

    notification-read.v1 (Cross-Device Sync)

    Pub/Sub topics and events — the boundary

    - + - +
    TopicMessage TypeBoundaryPublished by
    TopicMessage SchemaBoundaryPublished by
    notifications.capturednotification.v1Capture → Matchingingestor
    notifications.matchednotification.v1Matching → Deliveryfilter-service
    notifications.discardednotification.v1Matching contextfilter-service
    rules.changedRuleChangedEventMatching contextrule-api
    rules.changedrule-changed.v1Matching contextrule-api
    notifications.readnotification-read.v1Delivery contextnotification-history
    +

    Semantics:

    +
      +
    • All schemas are versioned for backward compatibility.
    • +
    • Adding optional fields is safe; removing or renaming fields requires a new schema version.
    • +
    • Each topic has exactly one designated publisher (see Publishing constraints below).
    • +
    • Services may subscribe to any topic; they depend on the message schema contract.
    • +

    Publishing constraints:

    • Only notification-ingestor may publish to notifications.captured.
    • From a08fc96dae65614af9abdc3b25a9669cfb57af98 Mon Sep 17 00:00:00 2001 From: John Gallagher Date: Thu, 6 Aug 2026 00:58:50 +0100 Subject: [PATCH 10/10] fix: make all schema headings consistent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema heading format is now: schema-name.v1 (Description) Changes: - notification.v1 (Notification Message) - rule.v1 (Rule Schema) [was: Rule schema] - rule-changed.v1 (Rule Lifecycle) - notification-delivery.v1 (Delivery Status & Persistence) - notification-read.v1 (Cross-Device Sync) Also: - Removed duplicate notification-read event documentation from history pane - Kept single authoritative version in contracts pane - Updated history pane to reference contracts section for full schema - Updated navigation label: 'notification-history & read events' → 'notification-history' - Added callout explaining per-location storage + event-driven broadcast pattern --- docs/index.html | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/docs/index.html b/docs/index.html index 64924cb..634e529 100644 --- a/docs/index.html +++ b/docs/index.html @@ -39,7 +39,7 @@

      Notify MVP

    • -
    • +
    @@ -343,7 +343,8 @@

    Repository structure

    pkg/contracts

    These structs are the slow layer. Changing them requires a new schema version.

    -

    notification.v1

    +

    notification.v1 (Notification Message)

    +

    Notification that flows through the Capture → Matching → Delivery pipeline.

    type Notification struct {
         ID              string            `json:"id"`               // UUID v7, dedup key
         UserID          string            `json:"user_id"`          // set by ingestor; trusted downstream
    @@ -368,7 +369,8 @@ 

    notification.v1

  • Adding optional fields is backward-compatible. Removing/renaming fields requires notification.v2.
  • -

    Rule schema

    +

    rule.v1 (Rule Schema)

    +

    Filter rule that controls which notifications are delivered to a user.

    type Rule struct {
         ID            string // UUID, stable identifier
         UserID        string // owner; "local" for MVP
    @@ -1213,28 +1215,19 @@ 

    Provenance

    -

    Cross-device synchronization: notification-read event

    -
    -

    When notification-history marks a notification as read, it publishes a notification-read event - to the notifications.read Pub/Sub topic. All connected SSE clients for that user receive the - event and immediately hide the notification from their display.

    -
    -
    {
    -  "event_id":        "UUID v7 — unique event identifier",
    -  "user_id":         "string — the user who marked it read",
    -  "notification_id": "UUID — which notification was marked read",
    -  "location":        "string — which location initiated (browser-web, app-android, etc)",
    -  "read_at":         "ISO 8601 — when it was marked read"
    -}
    -

    Pub/Sub attributes: user_id (used for per-user subscription filtering)

    -

    Delivery model: Each SSE connection subscribes to notifications.read filtered by - its user_id. When a read event arrives, the client removes the notification from the DOM.

    +

    Cross-device synchronization via notification-read events (Invariant 10)

    +

    When a notification is marked as read, notification-history publishes a notification-read.v1 + event to the notifications.read Pub/Sub topic (see + for full schema).

    +

    All SSE connections for that user subscribe to this topic filtered by user_id. When an event + arrives, the client receives it via SSE and immediately removes the notification from the DOM — providing + real-time cross-device synchronization without requiring refresh.

    -
    Why per-location storage + event-driven broadcast?
    -

    This design keeps the storage simple (per-location read status) while providing real-time cross-device UX. - If a user opens their Android phone hours later, they query the notification-history service and see the - read_at timestamp — the system is consistent. But for connected devices, the event provides immediate - feedback without them having to refresh.

    +
    Per-location storage + event-driven broadcast
    +

    Storage layer tracks independent read_at per location (browser-web, app-android). But Invariant 10 events + synchronize UX immediately across all connected clients. If a device goes offline and reconnects later, + it queries the history service and sees the correct read_at timestamp — eventual consistency without + requiring global database-level read status.