# Refinement Phase 9 — Observability, ops hardening, docs honesty & scale-later — Report (2026-07-13) **Track:** backend (observability/docs) + explicit deferrals · **Depends on:** nothing hard · **Gate:** `dotnet build` **0 new warnings** · `dotnet test` **407 pass** (402 prior + 5 new: audit-retention ×2, ticket-body encryption ×2, liveness ×1; the existing messaging suite now also exercises the encrypted body). This phase makes the running platform **diagnosable and honest**, and records the explicitly-deferred scale work so nobody mistakes it for missing MVP scope. No feature behaviour changed; the money/trust rules are untouched. ## Observability & ops hardening (finish before launch) ### 9.1 — Tracing added; one metrics stack; `requestId` = trace id - **One metrics stack.** Removed the duplicate **prometheus-net** stack (`UseMetricServer`/`UseHttpMetrics`/ `ForwardToPrometheus` + the three `prometheus-net*` packages). **OpenTelemetry is now the only metrics source**, scraped at `/metrics` via `UseOpenTelemetryPrometheusScrapingEndpoint()`. HTTP request metrics now come from the OTel ASP.NET Core instrumentation; the `mediator_meter` request-duration histogram (`MetricsBehaviour`) is now actually exported (added to the meter list — the old prometheus-net stack never captured it). - **Tracing.** Added `WithTracing` (ASP.NET Core + **EF Core** instrumentation) sharing one resource (`service.name = Baya.Web.Api`), so a cross-service money flow (webhook → confirm → ledger) is one trace. - **OTLP export is opt-in.** Traces + metrics export to an OTLP collector **only when `OpenTelemetry:Otlp:Endpoint` is set** — an MVP with Prometheus alone runs unchanged and no exporter spams an absent collector. - **`requestId` already carries the trace id** (`ApiResult.RequestId = Activity.Current.TraceId`) with `Activity.DefaultIdFormat = W3C` — a support ticket maps 1:1 to a trace with no extra wiring. - **New packages** (all cached, no restore risk): `OpenTelemetry.Exporter.OpenTelemetryProtocol` (1.15.3), `OpenTelemetry.Instrumentation.EntityFrameworkCore` (1.15.1-beta.1). ### 9.2 — Health checks broadened; liveness/readiness split - `/healthz/live` — process only (a dependency-free `self` check), so a dependency outage never restart-loops. - `/healthz/ready` — the app DB, the log DB (**deployed only** — its conn string is a placeholder in Dev/Testing), and a real **object-storage write round-trip** (`ObjectStorageWriteHealthCheck`: put → get → delete a probe blob). - `/HealthCheck` — the aggregate, retained for backward compatibility. The dead `currentUrl` line is gone. - **Redis** is noted as the next readiness check to add *when* it becomes a real dependency (>1 instance); not now. - `Baya.Infrastructure.Monitoring` now references `Baya.Application` (for the `IObjectStorage` probe) — a legitimate Infrastructure→Application edge, noted in the server Project map. ### 9.3 — Prod log level raised to Information+; no PII; dead ES sink removed - Deployed envs now log **Information+** (was Warning+, which dropped every Information-level audit trail), with framework categories held at Warning so the floor raise doesn't flood the sink. - **No secrets/PII in logs.** `LoggingSmsSender` **no longer logs the OTP code** (a login secret) in any environment — a developer gets it from the Development-only `GET /api/v1/dev/last_otp`. Clinical text / IBANs / phone numbers are already encrypted or masked before any handler logs. The `columnOptions` (previously built but never applied) are now wired to the SQL sink. - **Dead Elasticsearch sink resolved by deletion:** removed the commented ES sink block *and* the unused `Serilog.Sinks.Elasticsearch` package (this also revealed `Serilog.Sinks.File` was only a transitive of the ES package — added it explicitly). Log-table retention is documented as an ops/DBA responsibility (or ship logs to the OTLP collector). ### 9.4 — Audit-log retention as a scheduled job - New `AuditLogRetentionJob` (`IRecurringJob`, registered like the others) runs a **two-tier** retention sweep over the append-only `ops.AuditLogs`: **financial/verification** entity types (`Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`, `PlatformConfig`, `PartnerCenter`) keep a long legal window (`audit_retention_financial_days`, default **2555** ≈ 7 years); everyday rows a shorter one (`audit_retention_general_days`, default **730** ≈ 2 years). Cadence key `audit_retention_scan_cadence_hours` (24). - `IAuditLogger.PurgeExpiredAsync(...)` does the delete: oldest-first (Id is monotonic with `OccurredAt`), capped at 20 000 rows/run so a backlog drains across runs; age compared in memory (SQLite can't translate a `DateTimeOffset` predicate), delete is a single id-keyed `ExecuteDeleteAsync`. Idempotent. - Migration `RefinementPhase9TicketBodyEncryptionAndAuditRetention` seeds the three config keys (ids 24–26). ### 9.5 — `TicketMessage.Body` encrypted; gRPC reflection gated to Development - **Ticket bodies are the refund/dispute paper trail** (users type phone numbers, addresses, clinical detail) — now **encrypted at rest** through the existing `IFieldEncryptor` converter (wired in `ApplicationDbContext`, like every other PII column). The stored column is widened to `nvarchar(max)` (ciphertext is longer than plaintext); the 4000- char plaintext limit stays a boundary-validation rule (Open/PostMessage validators). Body is never a SQL search/ filter predicate (the admin thread read decrypts per row), so losing SQL-searchability is an accepted trade-off. - **gRPC decision — keep the plugin, gate reflection to Development.** The plugin exposes only the User service and the client is HTTP/JSON, but removing it is more invasive than the risk warrants. gRPC **reflection** (which advertises the full schema) is now registered/mapped **only in Development**. The HTTP/2-posture concern is already mitigated (refinement-phase-5 set Kestrel `Http1AndHttp2`), so the plugin shares the mixed-protocol listener (ALPN negotiates h2 for gRPC clients) — no dedicated port needed. ### 9.6 — Docs made honest - **Mocks-registry:** pruned the 7 **stale duplicate 🔴 rows** (`IDistributedLock`/`INurseSearch`/`IPaymentProvider`/ `ISettlementSplitProvider`/`IWebhookVerifier`/`IMoadianClient`/`ILicenseVerificationService`) the detailed rows already correct; the recurring-jobs row is the real in-process scheduler; the `IPaymentCaptureSimulator` row now reflects its 6.4 prod removal (fail-closed in prod; Dev/Testing mock is a test affordance). Added a phase-9 banner. - **REQ tracker:** already honest — refinement-phase-3 marked every delivered/deferred/resolved REQ; this phase ships no new contract, so no REQ status changed. (The pre-phase-3 "all 15 open" state the audit flagged is long fixed.) - **Architecture maps:** `server/CLAUDE.md` updated (observability wiring, the audit-retention cron, ticket-body encryption, the gRPC decision, the new Monitoring→Application edge); `runtime-services.md` updated (OTel consolidation, tracing, health split). ## Scale & later — explicitly NOT MVP (recorded, not built). Each has a written pull-trigger. | # | Deferred item | Where it lives today (the real MVP) | **Pull it when…** | | --- | --- | --- | --- | | 9.7 | **Elasticsearch read backend + outbox feeder** — `ElasticNurseSearch` + the CDC/outbox stream | `SqlNurseSearch` is real & correct; `Search:Backend` fails fast on any non-`sql` value | **SQL search shows strain** (latency/throughput on `nurse_search_index`). Build `ElasticNurseSearch` (same filters/sort/paging) + the outbox feeder off `ISearchIndexMaintainer`; keep SQL as the reconciliation source (`RebuildAsync`). | | 9.8 | **Analytics pipeline** — warehouse/stream sink | `IAnalyticsSink` writes `ops.SystemEvents` fire-and-forget (real, queryable) | **Product needs cross-event analytics** beyond SQL queries. Pipe `SystemEvents` to a warehouse/stream (e.g. Kafka→ClickHouse), keeping fire-and-forget semantics. | | 9.9 | **Holiday-calendar feed** — automated lunar-Hijri drift feed | `IHolidayCalendar` reads the seeded, manually-maintained `ops.IranianHolidays` table (real) | **The manual yearly refresh becomes a burden.** A **yearly ops-checklist item to top up the table is an acceptable MVP alternative** to a feed — the read interface stays. | | 9.10 | **Push/SMS notification channels** — SMS/FCM fan-out | `InAppNotificationDispatcher` writes real in-app `ops.Notifications`; non-InApp channels are dropped by design | **The notification UX demands** out-of-app reach. Fan out to SMS (via the phase-8 `ISmsSender`) and FCM push behind the same `INotificationDispatcher`. | | 9.11 | **Deferred product tables** — `organizations`, `organization_nurses`, `fraud_flags`, `recurring_booking_schedules`, `bnpl_settlement_entries`, availability slots, customer national-ID KYC, geo bulk import | All **verified absent**; each is a **pure additive migration** when product pulls it | **Product pulls the feature.** No structural blocker — additive migration + feature slice; nothing in the current schema needs to change first. | **These are decisions, not gaps.** SQL search, in-app notifications, and the manual holiday table are the real, correct MVP; Elasticsearch/analytics/push each has a concrete trigger above and stays out until then. ## How it was verified - **Build:** `dotnet build Baya.sln` — 0 new warnings (the pre-existing NU1510 + NU1903 transitive-dependency audit warnings are unrelated to this phase). - **Tests:** `dotnet test Baya.sln` — all green. New: `AuditLogRetentionTests` (two-tier purge + idempotency), `TicketMessageEncryptionTests` (encrypted at rest + round-trips on read), `HealthCheckApiTests` (liveness healthy without dependencies). - **Trace/requestId:** a request's `ApiResult.requestId` is `Activity.Current.TraceId` (W3C) — the same id a configured OTLP collector records. - **No PII in logs:** the OTP code is no longer logged in any environment; clinical text/IBANs are encrypted/masked. ## Follow-ups for later phases - Wire an OTLP collector (Grafana Tempo / Jaeger / OTEL Collector) in the deploy topology and set `OpenTelemetry:Otlp:Endpoint` to turn tracing export on. - When the first `redis` dependency lands (>1 instance), add its readiness check to `/healthz/ready`. - The NU1903 transitive-dependency vulnerability warnings (`Microsoft.OpenApi`, `SQLitePCLRaw`) are a separate dependency-bump task, out of this phase's scope.