refinement phase 9
This commit is contained in:
@@ -146,7 +146,7 @@ A 26-agent review/verify pass over the diff confirmed and fixed, pre-merge:
|
||||
- **i18n/UX:** inline initiate errors always use the localized copy (never raw `ApiError.message`); en
|
||||
`cta_pay` arrow points → (fa keeps ←); fa `error_body` matches the app's «بارگذاری … ممکن نشد» pattern;
|
||||
the C6 service-cost row carries the quantity (`row_service_cost_with_count`); the invoice issuer line
|
||||
uses the product spelling «بالینیار» (note: fa `common.brand` reads «بلینیار» — a pre-existing
|
||||
uses the product spelling «بالینیار» (note: fa `common.brand` reads «بالین یار» — a pre-existing
|
||||
wordmark/product-spelling divergence worth a product decision).
|
||||
- **Dark scheme:** EscrowNotice text/border use `--bal-primary` (the info token is an alert *background*
|
||||
and is illegible as dark-mode text); the print button temporarily flips `data-mui-color-scheme` to
|
||||
|
||||
@@ -8,6 +8,16 @@ seam updates its row. This is the checklist the team works through to go from "M
|
||||
Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live · 🟢◐ **real
|
||||
adapter shipped, config-selected (mock remains the default/fallback)** — the refinement-phase-8 state.
|
||||
|
||||
> **Refinement-phase-9 — docs honesty + observability (2026-07-13).** §7.6: pruned the **stale duplicate 🔴 rows**
|
||||
> (`IDistributedLock`/`INurseSearch`/`IPaymentProvider`/`ISettlementSplitProvider`/`IWebhookVerifier`/`IMoadianClient`/
|
||||
> `ILicenseVerificationService`) that the detailed rows below already correct; the recurring-jobs row is the real
|
||||
> in-process scheduler; `IPaymentCaptureSimulator` now reflects its 6.4 prod removal. **No seam was un-mocked** — the
|
||||
> deferred **Elasticsearch `INurseSearch` backend** (row) and the **SMS/push channels of `INotificationDispatcher`**
|
||||
> (row) stay explicitly out of MVP with the pull-triggers in their "Make it real →" columns (SQL search / in-app
|
||||
> notifications are the real MVP). Also removed the unused `Serilog.Sinks.Elasticsearch` package (dead sink block);
|
||||
> tracing/metrics unified on OpenTelemetry; audit-log retention is now a scheduled `IRecurringJob`; `TicketMessage.Body`
|
||||
> is encrypted at rest (§9.5). See `backend-phase-9-report.md` (refinement) for the full observability + deferral list.
|
||||
|
||||
> **Refinement-phase-8 — external rails go real (2026-07-13).** Every vendor rail below now has a **real HTTP
|
||||
> adapter behind the same seam**, config-selected via a per-rail **`Seams:*:Provider`** selector (default = the
|
||||
> mock, so an unconfigured environment is unchanged; a typo falls closed to the mock). Selecting a real provider
|
||||
@@ -39,11 +49,6 @@ adapter shipped, config-selected (mock remains the default/fallback)** — the r
|
||||
| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — `LoggingSmsSender` (`Baya.Infrastructure.CrossCutting/Seams/`) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in `AddCrossCuttingSeams`. **refinement-phase-0:** in **Development only**, `DevCapturingSmsSender` decorates it (via `AddDevelopmentOtpCapture`, called from `Program.cs` inside `IsDevelopment()`) to also capture the code in `DevOtpStore` for the `GET /api/v1/dev/last_otp/{phone}` bring-up helper — not wired / 404 outside Development | none today; real client will need `Seams:Sms:ApiKey` + `Seams:Sms:SenderLine` (+ gateway base URL) | 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to `Directory.Packages.props`; 2) implement `ISmsSender.SendOtpAsync`/`SendAsync` against it (template/pattern-based OTP send); 3) bind the new `Seams:Sms` options; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 5) keep the per-phone resend window + `otp` rate-limit policy exactly as-is; test with a real SIM | 🟡 |
|
||||
| `IObjectStorage` | backend-phase-0/6 | File storage — local-disk store under a scratch root (`LocalDiskObjectStorage`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:ObjectStorage:RootPath` (default: temp dir) | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🟡 |
|
||||
| `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme. **refinement-phase-7: this is the >1-instance scale-out gate** — a single-instance MVP intentionally keeps the in-proc cache (its generation-token invalidation is process-local); add Redis only when a second API instance runs | 🟡 (in-proc is correct single-instance) |
|
||||
| `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 |
|
||||
| `INurseSearch` | backend-phase-7 | Search — SQL over `nurse_search_index` | _tbd_ | Elasticsearch index + feeder; reimplement the interface | 🔴 |
|
||||
| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 |
|
||||
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم split — accepts any balanced legs | _tbd_ | Provider split-by-ratio to registered Shebas | 🔴 |
|
||||
| `IWebhookVerifier` | backend-phase-10 | Callback auth — always valid | _tbd_ | Per-provider HMAC/signature + server-side re-verify | 🔴 |
|
||||
| `IBnplProvider` | backend-phase-12 | BNPL — `MockBnplProvider` drives the full state machine (eligible→settled→reverted), settle returns `order − commission%` | `Seams:Bnpl:{CommissionRate,SettlementInstant,CreditCeilingIrr,NotEligibleMobile,ForceFailure,ReverseProviderCommission}` | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🟡 |
|
||||
| `IBnplProviderResolver` | backend-phase-12 | Per-`provider_code` selection — maps every known code to the one mock | _none_ | One concrete adapter per code; resolver returns the right one | 🟡 |
|
||||
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 at the boundary | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Config-driven per provider boundary | 🟡 |
|
||||
@@ -56,12 +61,10 @@ adapter shipped, config-selected (mock remains the default/fallback)** — the r
|
||||
| `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفهای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 |
|
||||
| `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 |
|
||||
| `IGeocoder` | backend-phase-4 | Address→lat/lng — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 |
|
||||
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
|
||||
| `IReviewModerationService` | backend-phase-14 | AI review pre-screen — `MockReviewModerationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `ScreenAsync(reviewText)` returns a `ModerationVerdict(Decision, Reason)` — a banned-word substring hit → `Reject` (`banned_word:{w}`); otherwise clean text → a human-review `Flag` by default (so the publish gate holds), or `Approve` when `AutoApproveClean` is set. The `SubmitReview` handler maps the verdict to the initial status (`Approve`→published, `Reject`→hidden, else pending) — **decision authority stays with `ModerateReviewCommand` (human override)**. Registered singleton in `AddCrossCuttingSeams` | `Seams:ReviewModeration:AutoApproveClean` (default `false`), `Seams:ReviewModeration:BannedWords` (default `scam,fraud,کلاهبردار`) | 1) pick a text classifier / LLM moderation endpoint, add its client package to `Directory.Packages.props`; 2) add `Seams:ReviewModeration:{ApiKey,BaseUrl}` options; 3) implement `ScreenAsync(reviewText)` → map the provider's toxicity/spam scores to `Approve`/`Flag`/`Reject` + a reason; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — `SubmitReviewCommand`/`ModerateReviewCommand` unchanged, and the human moderation path always overrides; 5) test clean/flagged/rejected dispositions + that the publish gate still holds for a `Flag` | 🟡 |
|
||||
| `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 |
|
||||
| `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 |
|
||||
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
|
||||
| `IPaymentCaptureSimulator` | backend-phase-9 | The **temporary conversion trigger** standing in for b10's real card capture. `MockPaymentCaptureSimulator` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic *succeeded* capture (a fake `gateway_reference` + a configurable `psp_fee_amount`) so `ConvertRequestToBookingCommand` is exercisable now; a config switch forces a *failed* capture (→ no booking is created). **This is the trigger, not a parallel money path** — registered singleton in `AddCrossCuttingSeams` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | In b10: 1) build the real card capture (`payment_transactions`, PSP/IPG client, webhook verify); 2) on a real `payment_transactions.succeeded`, call `ConvertRequestToBooking` **directly** (the same conversion command that computes the three-amount split + generates sessions) instead of this seam; 3) remove the `IPaymentCaptureSimulator` registration + `MockPaymentCaptureSimulator`; the conversion/idempotency logic is unchanged | 🟡 |
|
||||
| `IPaymentCaptureSimulator` | backend-phase-9 → **removed from prod refinement-phase-8 §6.4** | The **temporary conversion trigger** that stood in for b10's real card capture. **Prod now gets the fail-closed `DisabledPaymentCaptureSimulator`**; only Dev/Testing re-register the succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts via the b10 webhook confirm calling `ConvertRequestToBooking` directly). Registered in `AddCrossCuttingSeams` (prod) + re-registered in Dev/Testing from `Program.cs` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | Nothing further for prod — the real conversion trigger is the b10 webhook confirm. The Dev/Testing mock stays as a test affordance; drop it only if/when `bookings/convert` is retired | 🟢 (prod fail-closed; Dev/Testing mock is a test affordance) |
|
||||
| `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 |
|
||||
| `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync(ref, amount, idempotencyKey, ct)` → always `Succeeded`, echoes a deterministic refund ref (b11 refunds carry the booking+refund idempotency key so a retry never double-refunds). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 |
|
||||
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم settlement-sharing — `MockSettlementSplitProvider` (`.../Seams/`) records the split intent and returns `Settled` for any legs whose sum is positive; the platform never moves money. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config | 1) pick the acquirer's تسهیم API, implement `RegisterSplitAsync(bookingId, legs)` to register the split-by-ratio to each beneficiary's **registered IBAN** (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from `nurse_bank_accounts` (the b3 `matched_national_id` gate) and the platform SHEBA from config; 3) `GetSplitStatusAsync` polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) | 🟡 |
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user