Files
baya-monorepo/dev/post-phase/server/post-phase-backend-plan.md
T
2026-07-10 11:49:55 +03:30

42 KiB
Raw Blame History

Post-development backend plan — fixes & improvements

Audit date: 2026-07-10 · Scope: the completed backend chain (backend-phase-0 → 15) under server/ · Method: read-only audit of code + dev/ docs; every finding cites the file/line it was verified at. Companion documents: frontend-backend-gaps.md (the REQ-by-REQ contract reconciliation) and runtime-services.md (the deployment topology).

The chain is genuinely complete against its own specs: all 16 backend phases shipped, 358 tests are green, and the load-bearing invariants (balanced ledger, DB CHECKs, idempotency uniques, tenancy 404s, forward-only status machines) verifiably exist in code. What remains falls into eight coherent "post-phases", ordered so that each is a runnable unit of work: deployment blockers first, then a code-level money-correctness fix, then the contract batch that unblocks the frontend lane, then the infrastructure and vendor swaps the seam architecture was built for.

Bucket Theme Blocking what?
post-phase-1 Security & config hygiene Any non-local deployment
post-phase-2 Money-path correctness completion Ledger ⇄ bank reconciliation
post-phase-3 Frontend-unblock contract batch 11 of 12 client domains are still mock-primary
post-phase-4 Scheduling, locking & multi-instance readiness Unattended operation; >1 API instance
post-phase-5 Identity & trust rails go real Real nurses onboarding (OTP, KYC, docs)
post-phase-6 Money rails go real Real payments, payouts, tax
post-phase-7 Observability, audit & ops hardening Production diagnosability
post-phase-8 Scale & later Search scale, analytics, doc debt

Status legend used below — Current state always cites what the code does today.


post-phase-1 — Security & config hygiene (do before anything is deployed)

Everything in this bucket is small (S) and none of it changes behavior — but each item is a deployment blocker, and two of them are live credential leaks sitting in git today.

1.1 Rotate and remove the committed SQL Server sa connection string

  • Why: appsettings.json and appsettings.Development.json both commit a real connection string — public IP 87.107.152.16, login sa, plaintext password — for the app DB and the log DB. Anyone with repo access owns the database (all PII ciphertext + the encryption keys sit in the same repo, see 1.2). This directly violates root CLAUDE.md working agreement #6 ("Never commit secrets").
  • Current state: server/src/API/Baya.Web.Api/appsettings.json:3-4 and appsettings.Development.json:3-4 (byte-identical files); consumed at server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:41 and by the Serilog sink at server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs:44.
  • Change: rotate the sa password on that server (assume compromised); create a least-privilege app login; move both connection strings to user-secrets (dev) / environment variables (deploy); commit only a placeholder. Consider git filter-repo history scrubbing, and add a secret-scanning pre-commit hook.
  • Files/layers: appsettings*.json only (config).
  • Effort: S · Risk: low (config move) · Deps: none. Do this first.

1.2 Replace placeholder JWE signing/encryption keys and field-encryption keys

  • Why: the JWT/JWE SecretKey/Encryptkey are starter-template placeholders and the PII field-encryption keys are committed local-dev-…-change-me strings. If these defaults reach any shared environment, every token is forgeable and every encrypted PII column is decryptable. The access-token lifetime is also ~7 days (ExpirationMinutes: 10000) and RequireHttpsMetadata = false.
  • Current state: server/src/API/Baya.Web.Api/appsettings.json:7-8 (keys), :12 (expiry); Seams:FieldEncryption at appsettings.json:15-18; the JWE decryption key is wired at server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs:135 and RequireHttpsMetadata = false at :139. SymmetricFieldEncryptor derives a real AES-256-CBC key from whatever string is configured (server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SymmetricFieldEncryptor.cs:25) — the crypto is fine; the key management is dev-grade (mocks-registry row IFieldEncryptor 🟡 agrees).
  • Change: per-environment secrets (env vars / Key Vault / KMS per the registry's "make it real"), a sane access-token lifetime (≤ 60 min; refresh flow already exists), RequireHttpsMetadata = true outside Development, real Issuer/Audience values (currently "MyWebsite"). Note: rotating the field key requires a re-encryption migration for existing rows — do it before real PII exists.
  • Effort: S (config) + M if key-rotation tooling is wanted · Risk: medium (existing dev-DB ciphertext becomes unreadable — acceptable pre-launch) · Deps: 1.1.

1.3 Remove or environment-gate the seeded admin / qw123321 user

  • Why: every non-Testing boot creates a well-known admin account with a hardcoded weak password and the full admin role — in production too.
  • Current state: server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs:48 (CreateAsync(user, "qw123321")), invoked from server/src/API/Baya.Web.Api/Program.cs:102.
  • Change: read the bootstrap admin credentials from configuration and only seed when explicitly configured (or Development-only); force a password change on first login.
  • Effort: S · Risk: low · Deps: none.

1.4 Environment-gate the auto-seeded sandbox ZarinPal payment gateway

  • Why: SeedPaymentGatewaysAsync idempotently inserts an active sandbox ZarinPal gateway row (all-zeros merchant id) on every boot — a production DB would silently contain an active sandbox money gateway.
  • Current state: server/src/API/Baya.Web.Api/Program.cs:103server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:106-117 (sandbox row, config_json encrypted via the DbContext converter).
  • Change: seed only in Development/Testing, or seed is_active = false and require an admin to activate a real gateway (payment_gateways is already admin data).
  • Effort: S · Risk: low · Deps: none; pairs with 6.1.

1.5 Fix the Kestrel HTTP/2-only default

  • Why: Kestrel:EndpointDefaults:Protocols = "Http2" (set for the gRPC plugin) makes every endpoint HTTP/2-only. Browsers can't speak h2c, so any non-TLS hop — container health probes, an HTTP/1.1 reverse proxy leg, plain-HTTP Swagger — breaks. Locally it only works because https://localhost:5002 negotiates via ALPN.
  • Current state: server/src/API/Baya.Web.Api/appsettings.json:29-33 (both files). The gRPC plugin that motivated it serves a single duplicate OTP/token service (server/src/API/Plugins/Baya.Web.Plugins.Grpc/Services/UserGrpcServices.cs:18).
  • Change: default Http1AndHttp2; give gRPC its own Http2 endpoint if kept (see 7.5 for the keep-or-remove decision).
  • Effort: S · Risk: low · Deps: none.

1.6 Make rate limiting proxy-aware and align the two payment webhooks

  • Why: all rate-limit partitions key on RemoteIpAddress and no ForwardedHeaders middleware is registered — behind any reverse proxy every client shares one 100 req/min bucket (self-DoS). Separately, the two webhook siblings disagree: the BNPL webhook runs the 20/min sensitive policy while the card webhook has only the global 100/min fallback — one of them is wrong on purpose or both by accident.
  • Current state: partition key at server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs:69; global limiter :35; Program.cs has no UseForwardedHeaders (checked server/src/API/Baya.Web.Api/Program.cs). Webhooks: Controllers/V1/WebhooksBnplController.cs:28 (sensitive) vs Controllers/V1/WebhooksController.cs:25 ([AllowAnonymous], global only).
  • Change: add ForwardedHeaders middleware (trusting only the known proxy), partition on the resolved client IP, and pick one deliberate webhook policy (PSP callbacks are bursty — a dedicated webhook policy keyed per-provider is safer than sensitive).
  • Effort: S · Risk: lowmedium (limiter behavior changes) · Deps: deployment topology decision.

post-phase-2 — Money-path correctness completion

The ledger invariants verified clean (balanced groups throw at server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs:26,65,178,204; the four DB CHECKs exist — CK_Bookings_AmountSplit, CK_NursePayouts_NetSplit, CK_Refunds_LegSplit, CK_BnplTransactions_SettleSplit — per the EF configs and Migrations/ApplicationDbContextModelSnapshot.cs:345,3543,3842,206). This bucket closes the holes around those invariants.

2.1 Wire the unreachable BNPL/manual refund settlement (dead-end money state) — top code fix

  • Why: a card refund posts its refund_payable ↔ escrow_held clearing immediately. A BNPL-revert or manual-bank refund is left in processing with the clearing "deferred to reconciliation" — but no reconciliation path exists anywhere: Refund.MarkSucceededAsync has zero call sites, no admin endpoint/webhook/job performs processing → succeeded, and LedgerPosting.RefundPayableClearing has exactly one call site (the immediate card path). Every BNPL/manual refund permanently overstates escrow_held and strands refund_payable — the ledger will never reconcile with the bank.
  • Current state: server/src/Core/Baya.Domain/Entities/Refunds/Refund.cs:105 (uncalled); server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs:139-143 (clearing only when already Succeeded); :226-231 (BNPL/manual → MarkProcessing, never succeeded); the Processing → Succeeded edge exists unused in server/src/Core/Baya.Domain/Entities/Refunds/RefundTransitions.cs:16; admin surface is create+list only (Controllers/V1/AdminRefundsController.cs:33). The b11 handoff promised at least a manual trigger (dev/shared-working-context/backend/handoff/after-backend-phase-11.md:37).
  • Change: a ConfirmRefundSettlementCommand (admin POST admin_refunds/{id}/confirm_settlement, plus a BNPL-callback branch when the provider confirms customer cash-back) that transitions processing → succeeded, stamps settled_at, and posts LedgerPosting.RefundPayableClearing in the same commit; a mark_failed counterpart. Tests for both channels.
  • Files/layers: Application Features/Refunds/, API AdminRefundsController, Domain (rename MarkSucceededAsync — it's not async), tests.
  • Effort: M · Risk: medium (money path — but additive) · Deps: none; do before real BNPL (6.2).

2.2 Add the promised-but-missing FKs on the forward-dep columns

  • Why: b11 created refunds.ticket_id, nurse_clawbacks.original_payout_id / recovered_in_payout_id, and invoices.partner_center_id as FK-less nullable columns "until the target table ships". The targets all shipped (b13 nurse_payouts, b15 tickets/partner_centers) and the values are wired, but no phase added the constraints — referential integrity rests on application discipline, and the config comments are now false.
  • Current state: stale comment "no FK yet (tickets does not exist)" at server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/RefundConfig.cs:41; NurseClawbackConfig.cs:12 (comment says b13 "wires the FKs" — it didn't; b13's NursePayoutEngine migration touches no clawback FK); InvoicesConfig/InvoiceConfig.cs:11 (partner_center_id has no FK and no index); the b15 migration adds only FK_NurseProfiles_PartnerCenters_PartnerCenterId (Migrations/20260709232741_MessagingAndPartnerCenters.cs:293). Value-side wiring confirmed: CreateRefundCommand.Handler.cs:53-61 (auto-ticket), Refunds/NurseClawback.cs:50 (MarkRecovered), IssueInvoiceCommand.Handler.cs:63-64 (issuer/center).
  • Change: one additive migration adding the three FK sets (ON DELETE NO ACTION) + an index on invoices.partner_center_id; update the three config comments.
  • Effort: S · Risk: low (data is young; verify no orphans first) · Deps: none.

2.3 Extend IAuditable to the admin-decided money & trust entities

  • Why: the append-only audit_logs diff interceptor covers exactly three entities — PlatformConfig, PartnerCenter, Review. Admin decisions on refunds (approve/reject), payouts (process/retry/fail), and nurse verification (the trust-critical is_verified flip / suspend) leave no audit-diff row. For a trust-first escrow platform these are precisely the actions an auditor asks about. (The ledger itself is fine — append-only by construction, LedgerEntry is IEntity-only at server/src/Core/Baya.Domain/Entities/Payments/LedgerEntry.cs:13.)
  • Current state: the three implementors — Domain/Entities/Configuration/PlatformConfig.cs:12, Domain/Entities/PartnerCenters/PartnerCenter.cs:16, Domain/Entities/Reviews/Review.cs:15. Refund (Domain/Entities/Refunds/Refund.cs:77) and NursePayout (Domain/Entities/Payouts/NursePayout.cs:62) are not IAuditable; NurseVerification isn't either.
  • Change: add IAuditable to Refund, NurseClawback, NursePayout, NursePayoutBatch, NurseVerification (the interceptor at Persistence/Interceptors/AuditFieldInterceptor already handles any IAuditable); confirm [AuditRedacted] covers iban_snapshot before enabling.
  • Effort: SM · Risk: low (write-volume growth on audit_logs; see 7.4 archival) · Deps: none.

2.4 Close the refund channel-execute-before-commit crash window

  • Why: CreateRefundCommand executes the external channel call (PSP refund / BNPL revert) before the first DB commit — a crash between provider success and commit loses the record of an executed refund. The idempotency key means a retry won't double-refund, but nothing retries automatically and no record exists to reconcile against.
  • Current state: Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs:106-111 (channel executes), commit later in the same handler; idempotency key at :85.
  • Change: persist the refund row in pending state (commit) before the channel call, then execute and update — the standard two-phase intent/confirm shape the webhook handler already uses (claim-key-first at Features/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.Handler.cs:76-88).
  • Effort: M · Risk: medium (touches the refund state machine; full test pass required) · Deps: do together with 2.1.

2.5 Test the untested admin money paths

  • Why: WriteOffClawbackCommand — an admin action that posts a bad_debt ledger group — has zero tests (grep across server/src/Tests finds no WriteOff match). The webhook duplicate-race path (DbUpdateException on a concurrent same-key insert) is only exercised sequentially. Messaging has no Foundation-level handler tests (the is_internal boundary is covered only end-to-end in Tests/Baya.Test.Api/MessagingApiTests.cs:33).
  • Current state: handler at Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Handler.cs:26; coverage inventory: 49 Foundation + 33 Api test files; payout retry / webhook replay / clawback fork / rebuild convergence are tested (Tests/Baya.Test.Foundation/Payouts/PayoutHandlerTests.cs:211, Payments/PaymentWebhookTests.cs:64, Refunds/RefundHandlerTests.cs:81, Search/SearchIndexTests.cs:184).
  • Change: add write-off unit + API tests (balanced group, idempotency, 404/409 paths), a true racing webhook-insert test, and Foundation tests for PostMessage/GetTicketThread internal-flag handling.
  • Effort: SM · Risk: none · Deps: none.

2.6 Retire the orphaned refund_ticket_required config key

  • Why: b15 superseded the config-gated rule by unconditionally auto-opening a refund ticket, so the seeded key has zero production consumers and a now-false description ("Off until b15 ships the tickets table") — a small honesty debt that will mislead an operator.
  • Current state: seed row at Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs:49; auto-open at CreateRefundCommand.Handler.cs:53-61; only remaining reference is a test stub (Tests/Baya.Test.Foundation/Refunds/RefundsTestHost.cs:174).
  • Change: delete the seed row (migration) or repurpose it to gate whether a customer-visible ticket is required; update the description either way.
  • Effort: S · Risk: low · Deps: none.

post-phase-3 — Frontend-unblock contract batch

Full field-level detail, evidence, and priority ordering live in frontend-backend-gaps.md. Summary: of the 15 filed REQs, REQ-001 and REQ-015 are effectively done, REQ-010 is a doc fix, and the other 12 are undelivered; 11 of the client's 12 service domains still default to mock-primary, most gated on exactly these items. This bucket is one backend phase-sized batch of small DTO/endpoint additions:

  • 3.1 Booking-surface fields (S): variantPrice on BookingRequestDto, variantLabel (+ optional patientAge) on BookingRequestListItemDto (REQ-013/014).
  • 3.2 Identity/profile fields (SM): patient relation + conditions (REQ-005); customer name/preferred-language upsert (REQ-007); avatar upload endpoint + avatarUrl (REQ-006 — the only item needing multipart + IObjectStorage).
  • 3.3 Address fields (S): accept the client map pin on create/update (REQ-008 — matters for EVV accuracy later) + provinceId on CustomerAddressDto (REQ-009).
  • 3.4 Search & public profile (M — the single highest-leverage item): enrich NurseSearchResultDto with nurseName/avatarUrl (+ optional distanceKm) and add the aggregated public GET nurses/{id}/profile (REQ-012). Unblocks the discovery funnel (C2/C3).
  • 3.5 Verification details (SM): nurse-facing credential_details command + isRequired on VerificationStepDto (REQ-011 — without it the INO number/specialties are silently dropped).
  • 3.6 Auth polish (S): codeLength/expiresInSeconds on RequestOtpResult (REQ-002); machine-readable code (+ retryAfterSeconds) on OTP failures (REQ-003 — needs a small OperationResult/envelope extension, the only cross-cutting piece).
  • 3.7 Zero-code confirmations & doc fixes (S): answer REQ-001/004/015 in the tracker; fix the stale page_size occurrences in dev/contracts/domains/*.md (REQ-010 — the server verifiably binds camelCase pageSize); mark every delivered REQ delivered in … (all 15 currently read Status: open).

Effort: one M-sized phase overall · Risk: low (additive DTO fields; regenerate dev/contracts/openapi/swagger.v1.json after) · Deps: none — can run in parallel with post-phase-1/2.


post-phase-4 — Scheduling, locking & multi-instance readiness

4.1 Real job scheduler + register the four deferred crons

  • Why: only two recurring jobs exist (booking-request expiry every 1 min, notification retention every 24 h). The verification credential-expiry scan, the EVV no-show sweep, the weekly payout batch, and the Moadian reconciliation poll are admin-manual-only — their cadence config keys are seeded but nothing reads them on a schedule. Operationally today: credentials never re-expire, no-shows are never flagged, and nurses are not paid unless an operator clicks.
  • Current state: the two hosted services at Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:63,67 (intervals hardcoded at Services/Notifications/NotificationRetentionHostedService.cs:19 and Services/Booking/BookingRequestExpiryHostedService.cs:21). Manual triggers: Controllers/V1/AdminVerificationsController.cs:49 (scan_expiring), AdminEvvController.cs:37 (detect_no_shows), AdminPayoutsController.cs:44 (batch generate). Unconsumed cadence keys: verification_expiry_scan_cadence_hours, no_show_scan_cadence_hours, nurse_payout_interval_days (PlatformConfigConfig.cs:46,48,36). Note: no IJobScheduler interface exists — the registry row is aspirational naming; there is nothing to swap behind, only hosted services to re-home. No Hangfire/Quartz package is referenced (server/Directory.Packages.props — verified absent).
  • Change: adopt Hangfire (SQL Server storage — no new infra) or Quartz; move the two existing sweeps and add recurring jobs for expiry-scan, no-show, payout-batch generation (+ 2.1's reconciliation and 6.5's Moadian poll), each reading its seeded cadence key; keep the admin manual triggers as overrides. Payout processing (money-moving) can stay human-approved — schedule generation, keep process manual until trust is earned.
  • Effort: ML · Risk: medium (new infra dependency in-app; jobs must stay idempotent — they already are by design) · Deps: none hard; pairs with 4.2 for multi-instance.

4.2 Redis for ICacheService + IDistributedLock

  • Why: both are in-process today. Cache: fine single-instance, silently wrong (stale geo/catalog/config reads, generation-token invalidation not shared) the moment a second instance runs. Lock: the money-path mutex (booking:{id}:payment / :refund) is a per-key SemaphoreSlim — no cross-instance protection (DB uniques remain the correctness backstop, as designed, but the lock is doing nothing across nodes).
  • Current state: CrossCutting/Seams/MemoryCacheService.cs:11 and InProcessDistributedLock.cs:14-22, registered at CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs:27,61. No Redis package referenced.
  • Change: add StackExchange.Redis; RedisCacheService (same key/TTL scheme) + RedisDistributedLock (SET NX PX + token-checked release, lease ≥ the longest money handler); config-selected registration per the registry's make-it-real steps (rows ICacheService, IDistributedLock).
  • Effort: M · Risk: medium (lock semantics under expiry; keep DB uniques authoritative) · Deps: Redis service (see runtime-services.md).

4.3 Separate migrations from boot (multi-instance + least privilege)

  • Why: every non-Testing boot runs MigrateAsync + three seeders — concurrent instance start-ups race on DDL (no distributed lock exists at boot), and the app login needs permanent DDL rights.
  • Current state: server/src/API/Baya.Web.Api/Program.cs:99-104; Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:92 (MigrateAsync unconditional).
  • Change: a deploy-time migration step (dotnet ef database update in CI, or a --migrate one-shot mode) and boot-time schema check instead of apply; seeders become idempotent deploy steps.
  • Effort: SM · Risk: low · Deps: CI/CD pipeline exists.

post-phase-5 — Identity & trust rails go real

Ordered by user impact: nobody can log in without 5.1.

5.1 Real SMS gateway behind ISmsSenderlaunch-critical

  • Why: OTP delivery is a log statement; no real user can ever log in. This is the single seam standing between the platform and its first real session.
  • Current state: CrossCutting/Seams/LoggingSmsSender.cs:12-16 (logs the code, phone last-4), registered at ServiceCollectionExtension.cs:32. No Seams:Sms options exist yet (verified: SeamOptions.cs has no Sms group).
  • Change: per registry row ISmsSender: pick Kavenegar/Ghasedak/SMS.ir, add Seams:Sms:{ApiKey, SenderLine,BaseUrl} options + package, implement pattern/template OTP send, config-selected registration. Keep the per-phone resend window and otp rate policy untouched.
  • Effort: M · Risk: low (isolated seam) · Deps: vendor account.

5.2 Real Shahkar + e-KYC vendors (IShahkarVerifier, IIdentityKycProvider)

  • Why: nurse verification currently passes any well-formed input (mock passes everything except two configured magic values) — the trust engine's automated steps assert nothing real.
  • Current state: CrossCutting/Seams/MockShahkarVerifier.cs:17-37, MockIdentityKycProvider.cs:17-25; registrations :45-46; config defaults SeamOptions.cs:157-184. The pipeline already persists external_response_json and handles shared-SIM/mismatch as explicit states — the handler side is ready.
  • Change: per registry rows: one Finnotech-style bridge client for both استعلام‌ها; keep shared-SIM as a handled failure; persist real vendor refs. The phone-change re-trigger already works (ShahkarVerifiedAt reset on phone change — b2).
  • Effort: ML (vendor onboarding dominates) · Risk: medium (real-world failure modes) · Deps: vendor contract; 5.1 not required but sensible first.

5.3 Real استعلام شبا (IBankAccountOwnershipVerifier)

  • Why: the b13 first-payout gate (matched_national_id = 1) is currently satisfied by a mock that matches every IBAN except one magic value — real money would flow against unverified account ownership.
  • Current state: CrossCutting/Seams/MockBankAccountOwnershipVerifier.cs:17-26 (nurseNationalId deliberately ignored, comment :21-22); registration :36; defaults SeamOptions.cs:204.
  • Change: per registry row; verify the payout gate end-to-end (is_primary=1 AND is_verified=1 AND matched_national_id=1 skip-with-reason path already exists in b13).
  • Effort: M · Risk: medium (money gate) · Deps: same vendor family as 5.2 — bundle them.

5.4 Real geocoder (IGeocoder) behind Neshan

  • Why: address coordinates (and therefore the EVV distance check) are deterministic fakes jittered ±5 km around 8 hardcoded city centroids — EVV mismatch alerts are currently noise. Accepting the client pin (REQ-008, post-phase-3) reduces but doesn't remove the need.
  • Current state: CrossCutting/Seams/MockGeocoder.cs:15-52; registration :40; Seams:Geocoding is one of only three seam sections present in appsettings.json:22-26.
  • Change: per registry row (Neshan client, rate-limit/retry, keep the null-coordinate path).
  • Effort: M · Risk: low · Deps: REQ-008 first (pin > geocode for EVV).

5.5 Real object storage (IObjectStorage) — MinIO/S3/ArvanCloud

  • Why: verification documents (and future avatars/invoice PDFs) live on the API host's local disk under a temp root — non-durable, non-shared, and GetUrl returns a file:// URI rather than a presigned URL, so the b6 "short-lived signed URL" contract is only shape-deep.
  • Current state: CrossCutting/Seams/LocalDiskObjectStorage.cs:12-56 (temp-dir fallback :20, file:// URL); Seams:ObjectStorage:RootPath empty in appsettings.json:19-21.
  • Change: per registry row: S3-compatible client, presigned PUT/GET with expiry, bucket + creds from config; migrate any existing dev files or reset.
  • Effort: M · Risk: low · Deps: storage service; REQ-006 (avatar) builds on this.

5.6 Accept manual as the real path for MoH/INO credentials and partner licensing (document, don't build)

  • Why: ICredentialVerifier and ILicenseVerificationService return "needs manual review" by design — there is no public MoH/INO/eNamad B2B API today. The admin review flows are the real mechanism; the seams exist so a portal API can slot in if one appears.
  • Current state: CrossCutting/Seams/MockCredentialVerifier.cs:18, MockLicenseVerificationService.cs:26; registry rows agree ("no public B2B API today").
  • Change: none in code. Mark these 🟡 rows as "manual = intended MVP state" in the registry so they stop reading as debt.
  • Effort: S (docs) · Risk: none.

post-phase-6 — Money rails go real

The seam shapes are faithful (idempotency keys, server-side re-verify, upsert-first webhooks are already the handler behavior — verified at HandlePaymentWebhookCommand.Handler.cs:31-88), so each swap is an adapter, not a redesign. Do 2.1 first so the BNPL refund path is complete before real money uses it.

6.1 Real PSP/IPG + webhook signatures + تسهیم (IPaymentProvider, IWebhookVerifier, ISettlementSplitProvider)

  • Why: card capture, callback authenticity, and the settlement split are all deterministic mocks (VerifyAsync always succeeds echoing the expected amount; any callback is "validly signed" unless it contains a magic marker; any balanced split "settles"). The legal تسهیم model (provider splits to registered IBANs; platform never holds funds) only exists as an interface.
  • Current state: CrossCutting/Seams/MockPaymentProvider.cs:14-24, MockWebhookVerifier.cs:15-21, MockSettlementSplitProvider.cs:13; registrations :58-60; merchant creds intended to come from the encrypted payment_gateways.config_json (seed at Persistence/ServiceCollectionExtensions.cs:109-117).
  • Change: per registry rows 3941: one acquirer-with-تسهیم (ZarinPal/Sadad/Vandar/Jibit); real InitPaymentAsync/VerifyAsync/RefundAsync; per-provider HMAC verification of the raw body; a provider registry/factory selected per gateway row; persist full gateway responses.
  • Effort: L · Risk: high (real money; Shaparak certification lead time) · Deps: merchant registration; 1.4; 2.1; 4.2 (real lock) strongly recommended.

6.2 Real BNPL adapters (IBnplProvider / IBnplProviderResolver)

  • Why: the full BNPL state machine runs against one mock provider; per-contract commission and non-instant settlement are simulated by config.
  • Current state: CrossCutting/Seams/MockBnplProvider.cs:17-53, MockBnplProviderResolver.cs:14-17; registrations :72-74; Seams:Bnpl:* defaults SeamOptions.cs:98-120. bnpl_settlement_entries (tranched settlement) is modeled-but-not-built by design (dev/shared-working-context/backend/handoff/after-backend-phase-12.md:40).
  • Change: per registry row 46: SnappPay (OAuth verb set) and/or Digipay adapters, creds from encrypted gateway config, Toman↔IRR only via ICurrencyNormalizer, per-contract commission read from the settle response, resolver registered per provider_code.
  • Effort: L · Risk: high · Deps: 2.1 (revert clearing), 6.1 patterns, provider contracts.

6.3 Real PAYA/SATNA payout rail (IBankTransferProvider) + async reconciliation

  • Why: payouts "settle" instantly in the mock, collapsing the real submitted → paid/failed async reconciliation; no money reaches nurses.
  • Current state: CrossCutting/Seams/MockBankTransferProvider.cs:18-31; registration :82; Seams:BankTransfer defaults SeamOptions.cs:61-72. The status machine, batch idempotency key, nurse_payout_booking_links UNIQUE (Persistence/Configuration/PayoutsConfig/NursePayoutBookingLinkConfig.cs:22), and PAYA/SATNA threshold selection already exist.
  • Change: per registry row 23: Jibit/Vandar/Sadad payout API, source settlement account config, the async callback that flips submitted → paid/failed, batch caps/minimums. Keep whole-batch/single-row failure + retry semantics (already tested at Tests/.../Payouts/PayoutHandlerTests.cs:211).
  • Effort: L · Risk: high (irreversible transfers — the UNIQUE link + ledger-exists guard are the backstops, and they're in place) · Deps: 5.3 (real ownership check) before real runs; 4.1 for the weekly trigger.

6.4 Remove IPaymentCaptureSimulator (the registry's own end-state)

  • Why: the b9 temporary conversion trigger was supposed to be removed once b10's real capture shipped (registry row 37, "make it real" step 3). b10 shipped; the seam and registration remain. Harmless as a test trigger, but it's a second way to mint a booking without a payment row — undesirable once real money exists.
  • Current state: registered at CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs:52; Seams:PaymentCapture options SeamOptions.cs:142.
  • Change: move the simulator into the test host (b9's tests are its only legitimate consumer) and drop the production registration; or gate the registration to Development/Testing.
  • Effort: S · Risk: low (b9 Convert tests must keep a path) · Deps: none.

6.5 Real Moadian submission + reconciliation (IMoadianClient)

  • Why: e-invoicing to سامانه مودیان is a legal obligation; today every invoice stays moadian_status = pending forever (mock leaves it pending; no reconciliation job or endpoint exists — the only ApplyMoadianResult caller is issue-time, Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs:72).
  • Current state: CrossCutting/Seams/MockMoadianClient.cs:15-25; registration :65; Seams:Moadian:ForceRegistered default false.
  • Change: enrollment (memory/economic code + signing cert), real SubmitAsync, and the pending → submitted → registered/failed reconciliation job (register it under 4.1). The invoice number sequence and VAT-on-commission math are already correct and tested.
  • Effort: ML (enrollment dominates) · Risk: medium · Deps: 4.1 (scheduler) for the poll.

6.6 Decide the partner-center settlement rail (currently: resolver without money)

  • Why: b15 resolves merchant-of-record per booking and stores each center's encrypted settlement_iban + commission_rate, but no money path pays a center or applies its rate — the b15 report itself lists the settlement rail as a follow-up.
  • Current state: resolver + invoice wiring real (Persistence/Repositories/PartnerCenterRepository.cs:118, IssueInvoiceCommand.Handler.cs:63); IBankTransferProvider consumed only by nurse payouts (Controllers/V1/AdminPayoutsController.cs:44); follow-up noted at dev/shared-working-context/reports/backend-phase-15-report.md:83.
  • Change: product decision first (does a merchant-of-record center receive the commission split at launch, or is it bookkeeping-only?). If money moves: a center-settlement ledger account + payout command reusing the b13 machinery.
  • Effort: ML (if built) · Risk: medium · Deps: product decision; 6.3.

post-phase-7 — Observability, audit & ops hardening

7.1 Add tracing and consolidate the two metric stacks

  • Why: OTel is metrics-only (no WithTracing, no OTLP exporter) — cross-service money flows (webhook → confirm → ledger) can't be traced in production. Two overlapping Prometheus stacks run simultaneously (OTel's AddPrometheusExporter + prometheus-net's UseMetricServer/UseHttpMetrics).
  • Current state: Monitoring/Configurations/OpenTelemetryConfigurations.cs:11-21; PrometheusMetricsConfigurations.cs:11; W3C activity format set but unexported (Program.cs:32); Serilog already enriches with span ids (LoggingConfiguration.cs:24).
  • Change: add WithTracing (AspNetCore + EF instrumentation) exporting OTLP; pick one metrics stack; wire trace-id into the ApiResult.requestId for support correlation.
  • Effort: SM · Risk: low · Deps: an OTLP-capable collector (optional at MVP; Prometheus alone is acceptable — see runtime-services.md).

7.2 Broaden health checks and split readiness/liveness

  • Why: the single check is app-DB connectivity; the log DB, object-storage root, and (future) Redis/PSP get no signal. A deploy can pass health while logging or uploads are broken.
  • Current state: Monitoring/Configurations/HealthCheckConfigurations.cs:17 (SQL Server only), /HealthCheck endpoint :28, dead currentUrl variable :20.
  • Change: add checks for logDb, object storage (write probe), Redis when 4.2 lands; tag checks and expose /healthz/live (process) vs /healthz/ready (dependencies); remove the dead line.
  • Effort: S · Risk: low · Deps: tracks new infra as it arrives.

7.3 Revisit production log levels and the notification-channel plan

  • Why: deployed environments write only Warning+ to the SQL sink — every Information-level audit trail (logins, money operations context) is dropped in production while Development keeps it. Also note: OTP codes are currently logged by design (LoggingSmsSender) — that must not survive 5.1.
  • Current state: CrossCutting/Logging/LoggingConfiguration.cs:40-53; Elasticsearch sink referenced but commented out (:58-70; package still pinned at server/Directory.Packages.props:51).
  • Change: Information+ to the sink with table retention (or a file/OTLP sink), structured category filters; delete the dead Elastic sink block + package (or revive it deliberately); verify no PII is logged (the SMS mock's OTP log disappears with 5.1).
  • Effort: S · Risk: low · Deps: none.

7.4 Audit-log growth & archival

  • Why: audit_logs is append-only with no archival or retention (deferred since b1); 2.3 will grow it faster. The notification purge job is the only retention job in the system.
  • Current state: deferral recorded at dev/phases/backend/backend-phase-1.md:124; no purge/archive job exists for ops.AuditLogs (only NotificationRetentionHostedService).
  • Change: a retention/archival policy (cold table or export) as a 4.1 job; define legal retention for money/verification audit rows first.
  • Effort: SM · Risk: low · Deps: 4.1.

7.5 Decide TicketMessage.Body encryption and the gRPC plugin's fate

  • Why (tickets): ticket messages are the refund/dispute paper trail — users will type phone numbers, addresses, and clinical details. TicketMessage.Body is plaintext with no documented decision, unlike BookingRequest.CustomerNotes which carries an explicit "deliberately plaintext" comment (Domain/Entities/Booking/BookingRequest.cs:45).
  • Why (gRPC): the plugin duplicates the OTP/token flow only, forces the HTTP/2 posture (1.5), and enables reflection unconditionally — cost without a consumer (the Next.js client is HTTP/JSON only).
  • Current state: Domain/Entities/Messaging/TicketMessage.cs:20; Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs:14-23, Services/UserGrpcServices.cs:18-53.
  • Change: (a) either encrypt Body via the existing converter pattern (accepting the search/ops cost — admin thread reads already decrypt per-row elsewhere) or add the explicit "deliberately plaintext" decision comment + docs; recommend encrypting. (b) remove the gRPC plugin or disable reflection outside Development and give it a dedicated HTTP/2 endpoint.
  • Effort: SM · Risk: low · Deps: 1.5 pairs with (b).

7.6 Keep the docs honest (registry + tracker + map)

  • Why: the mocks-registry contains stale duplicate rows — the early block still says 🔴 "not built" for IDistributedLock/INurseSearch/IPaymentProvider/ISettlementSplitProvider/IWebhookVerifier/ IMoadianClient/ILicenseVerificationService while later rows correct all seven (e.g. rows 15/16 vs 38/42). IJobScheduler is listed as a seam but no such interface exists. All 15 REQs read Status: open. Stale instructions are worse than none (root CLAUDE.md rule 7).
  • Current state: dev/shared-working-context/reports/mocks-registry.md:15-19,32,36 (stale block) vs :38-50 (corrected rows); dev/shared-working-context/frontend/requests/for-backend.md (all open).
  • Change: prune the stale registry block, rename the IJobScheduler row to "recurring jobs (hosted services)", mark delivered/answered REQs, and note IPaymentCaptureSimulator's intended removal (6.4).
  • Effort: S · Risk: none · Deps: post-phase-3 outcomes.

post-phase-8 — Scale & later (explicitly not MVP)

  • 8.1 Elasticsearch read backend + outbox feederSqlNurseSearch is real and correct (Persistence/Services/Search/SqlNurseSearch.cs:18); Search:Backend fails fast on any non-sql value (ServiceCollectionExtensions.cs:74-78). Build ElasticNurseSearch + the outbox/CDC feeder per registry rows 38/43 only when SQL search shows strain. Effort: L.
  • 8.2 Analytics pipelineIAnalyticsSink writes ops.SystemEvents rows fire-and-forget (Persistence/Services/Analytics/AnalyticsSink.cs:15-35); pipe to a warehouse/stream when product needs it. Effort: M.
  • 8.3 Holiday-calendar feed — the table is manually maintained; a lunar-Hijri drift shifts payout dates (Persistence/Services/Holidays/HolidayCalendarService.cs:25-45). A yearly ops checklist item is an acceptable alternative to a feed. Effort: S.
  • 8.4 Push/SMS notification channelsInAppNotificationDispatcher silently drops non-InApp channels (Persistence/Services/Notifications/InAppNotificationDispatcher.cs:17); add channel fan-out (SMS via 5.1's sender, FCM push) when the mobile/notification UX demands it. Effort: M.
  • 8.5 Deferred product tablesorganizations, organization_nurses, fraud_flags, recurring_booking_schedules (b15), bnpl_settlement_entries (b12), nurse availability slots (b5/b8), customer national-ID KYC (b3), geo bulk import (b4) — all verified absent and all pure additive migrations when product pulls them (evidence: dev/phases/backend/backend-phase-15.md:333-336, after-backend-phase-12.md:40, backend-phase-3.md:188, backend-phase-4.md:194; model snapshot clean).

Suggested sequencing

post-phase-1 (security)  ──┬──►  post-phase-2 (money correctness)  ──►  post-phase-6 (money rails)
                           │
post-phase-3 (contract batch — parallel, unblocks frontend f9f15)
                           │
post-phase-4 (scheduler/Redis)  ──►  needed by 6.3/6.5 triggers
post-phase-5 (trust rails; 5.1 SMS is launch-critical, schedule early)
post-phase-7 (observability — start anytime, finish before launch)
post-phase-8 (later)

The two items that should not wait for their bucket: 1.1 (rotate the committed sa credentials — today) and 2.1 (the unreachable refund clearing — before any real BNPL/manual refund exists).