2f33b1cinitial_status = Some(Queued) when recipient is in quiet window. Deferred notifications are distinguishable from normal Pending ones by status.
2f33b1cEXPOSE 8080 to EXPOSE 8085, now matching SERVER_PORT default in src/config.rs.
fd4a2cfOption<EventProducer> and emit template.created, template.updated, template.deleted. Graceful degradation on publish failure.
5981652002_preferences_soft_delete.sql adds deleted_at + partial index. Repository delete() now issues UPDATE SET deleted_at = now(). Upsert resets deleted_at = NULL.
pact crate in Cargo.toml dev-dependencies. No contract test files found under tests/ or anywhere in the project tree.
Global CLAUDE.md requires “Contract tests: Pact between each service pair” and the service checklist mandates “Write API contract tests (Pact) FIRST.”
This is a process and testing compliance gap — it does not affect runtime behavior of the deployed service.
| ID | Criterion | Status | Evidence |
|---|---|---|---|
| AC-001 | Notification entity domain model with required fields | MET | src/domain/notification.rs — Notification struct with all fields incl. deleted_at |
| AC-002 | Template entity with subject/body templates and is_active flag | MET | src/domain/template.rs |
| AC-003 | Preference entity with channel, enabled flag, quiet hours | MET | src/domain/preference.rs — now includes deleted_at (migration 002) |
| AC-004 | Channel enum: Email, Sms, InApp, Webhook | MET | src/domain/enums.rs + migrations/001_init.sql channel_type ENUM |
| AC-005 | Priority enum: Low, Normal (default), High, Urgent | MET | src/domain/enums.rs — Normal is #[default]; SQL DEFAULT 'normal' |
| AC-006 | NotificationStatus: Pending, Queued, Sent, Delivered, Failed, Read | MET | src/domain/enums.rs — all 6 variants |
| AC-007 | Validation: notification requires body XOR template_id | MET | src/domain/dto.rs:66-84 |
| AC-008 | Validation: template name and body_template not empty | MET | src/domain/dto.rs:86-104 |
| AC-009 | Template rendering: mustache-style variable substitution | MET | src/domain/template.rs:23-47 — render_body() + render_subject() |
| AC-010 | Quiet hours logic with midnight-wrap support | MET | src/domain/preference.rs:21-33 — handles 22:00–06:00 wrap |
| AC-011 | Reject notification if channel disabled | MET | src/service/notification_service.rs:31-37 |
| AC-012 | Reject if referenced template is inactive | MET | src/service/notification_service.rs:55-60 |
| AC-013 | POST /api/v1/notifications → 201 Created | MET | src/api/notifications.rs:12-36 |
| AC-014 | GET /api/v1/notifications → paginated list scoped to tenant | MET | src/api/notifications.rs:39-46 — per_page capped at 100 |
| AC-015 | GET /api/v1/notifications/{id} | MET | src/api/notifications.rs:49-57 |
| AC-016 | GET /api/v1/notifications/recipient/{recipient_id} | MET | src/api/notifications.rs:60-71 |
| AC-017 | PATCH /api/v1/notifications/{id}/read → mark as read | MET | src/api/notifications.rs:74-98 — sets read_at timestamp |
| AC-018 | PATCH /api/v1/notifications/{id}/status | MET | src/api/notifications.rs:102-136 |
| AC-019 | DELETE /api/v1/notifications/{id} → soft delete 204 | MET | src/api/notifications.rs:139-155 |
| AC-020 | POST /api/v1/templates → 201 Created | MET | src/api/templates.rs:11-30 |
| AC-021 | GET /api/v1/templates → paginated list | MET | src/api/templates.rs:33-39 |
| AC-022 | GET /api/v1/templates/{id} | MET | src/api/templates.rs:43-51 |
| AC-023 | PUT /api/v1/templates/{id} → partial update | MET | src/api/templates.rs:54-71 — COALESCE for optional fields |
| AC-024 | DELETE /api/v1/templates/{id} → soft delete 204 | MET | src/api/templates.rs:74-90 |
| AC-025 | PUT /api/v1/preferences/{user_id} → upsert | MET | src/api/preferences.rs:11-30 — ON CONFLICT DO UPDATE; resets deleted_at = NULL |
| AC-026 | GET /api/v1/preferences/{user_id} | MET | src/api/preferences.rs:33-42 — filters WHERE deleted_at IS NULL |
| AC-027 | GET /health → liveness 200 OK | MET | src/api/health.rs |
| AC-028 | GET /health/ready → readiness + DB connectivity | MET | src/api/health.rs — SELECT 1; returns 503 if DB unreachable |
| AC-029 | PostgreSQL schema notifications with all required tables | MET | migrations/001_init.sql — 3 tables + 3 enum types |
| AC-030 | RLS policies on all 3 tables | MET | migrations/001_init.sql — ENABLE ROW LEVEL SECURITY + CREATE POLICY tenant_isolation_* on all 3 |
| AC-031 | RLS enforced at runtime via SET LOCAL app.tenant_id | MET | src/db.rs:begin_tenant_tx() — integration test test_rls_enforced_at_database_level |
| AC-032 | Soft delete on notifications | MET | src/repository/notifications.rs:220-241 |
| AC-033 | Soft delete on templates | MET | src/repository/templates.rs:156-177 |
| AC-034 | Unique constraint on templates (tenant_id, name) | MET | migrations/001_init.sql — constraint + AppError::Conflict mapping |
| AC-035 | Performance indexes on tenant-scoped tables | MET | migrations/001_init.sql + partial idx_preferences_active in 002 |
| AC-036 | JWT signature verification with cryptographic validation | MET | src/api/auth.rs:verify_jwt() — jsonwebtoken crate; 13+ unit tests |
| AC-037 | tenant_id from JWT → TenantContext | MET | src/api/auth.rs — X-Tenant-Id header fallback for M2M |
| AC-038 | EventProducer instantiated and wired in main.rs | MET | src/main.rs — used by notification service and template handlers |
| AC-039 | Notification events emitted on all state changes | MET | src/service/notification_service.rs:86-101, 149-171 |
| AC-040 | CloudEvents v1.0 envelope format | MET | src/events/producer.rs — CloudEvent struct with all required fields |
| AC-041 | Topic events.notification-hub; tenant_id as partition key | MET | src/events/producer.rs — const TOPIC; FutureRecord::key(&tenant_id) |
| AC-042 | JSON structured logging with tenant_id + correlation_id | MET | src/main.rs — tracing_subscriber::fmt().json() |
| AC-043 | Quiet hours: defer notification when in quiet window | MET FIXED | deferred=true → initial_status=Some(Queued) in notification_service.rs |
| AC-044 | Dockerfile port consistency with runtime default | MET FIXED | Dockerfile EXPOSE 8085 matches SERVER_PORT default in src/config.rs |
| AC-045 | Template events emitted (created / updated / deleted) | MET FIXED | src/api/templates.rs — create(), update(), soft_delete() emit CloudEvents |
| AC-046 | Soft delete on preferences (no hard deletes) | MET FIXED | migrations/002_preferences_soft_delete.sql + repository UPDATE SET deleted_at |
| AC-047 | Pact contract tests between notification-hub and consumers | MISSING | No pact crate in Cargo.toml. No contract test files found anywhere. |
| 47 criteria evaluated | 46 MET · 0 PARTIAL · 1 MISSING | 4 resolved this cycle | |
Incremental review from c386b451 (2026-03-19). Four of five previously-flagged deviations resolved across commits 5981652, fd4a2cf, and 2f33b1c.
Quiet-hours implementation: The deferral approach sets initial_status = Some(NotificationStatus::Queued) when a recipient is in their quiet window. Deferred notifications are distinguishable from normally-pending ones by status. A background worker to dispatch Queued notifications at the end of the quiet window is not yet implemented but is a separate concern outside the current acceptance criteria.
Preferences soft-delete: The upsert operation correctly resets deleted_at = NULL on conflict, restoring previously-deleted preferences when the user re-enables them. The partial index idx_preferences_active WHERE deleted_at IS NULL keeps query performance unaffected by accumulating soft-deleted rows.
Template CloudEvents: Graceful degradation (log error, do not abort API response) is consistent with the notification event pattern — the service does not make API success dependent on Redpanda availability.
Remaining gap (AC-047): LOW severity, does not affect runtime correctness. Should be addressed before the service enters production integration with consumer services (Workflow Engine, Form Engine) that listen on events.notification-hub.
Service is at 46/47 criteria met (98%). Status remains non-compliant per the rule that criteria_missing == 0 is required for compliance.