Skip to content

OSS service stack

Sanmopia uses OSS services where they remove generic platform work without leaking vendor-shaped models into the domain.

ServiceOSSOwned capabilitySanmopia boundary
cms-devStrapiAdmin authoring, media library, editorial CRUDBackend owns publication lifecycle, RLS, and public contracts
novu-api / novu-worker / novu-ws / novu-dashboardNovuWorkflow execution, subscriber inbox, provider routingBackend owns communication triggers, dispatch idempotency, and audit
workflow-state-devRestateDurable workflow state engine, retry, idempotent command orchestrationBackend starts Sanmopia workflows; Supabase remains the ledger, read model, and RLS store
otel-collectorOpenTelemetry CollectorVendor-neutral telemetry receiver, processor, exporter, internal collector metricsBackend emits OTLP; domain/application modules stay telemetry-product-free
authorization-migrate + authorization-dev + authorization-schema-bootstrapSpiceDB + ZedRelationship permission graph, datastore migration, and schema bootstrapSupabase stores business rows; backend maps reservation-stage capabilities
policy-devOPALaw, state, and context policy-as-codeBackend evaluates policy gates before state transitions; complements SpiceDB and Supabase RLS
Supabase stackSupabasePostgres, Auth, Storage, Realtime, Cron, Queues, VectorDomain ports decide when platform services are used
Backend package dependencyOR-Tools CP-SATBranch-wide assignment optimizationAdapter solves caregiver assignment; domain keeps solver-free assignment facts and ports
novu-localstackLocalStackS3-compatible Novu development storageNot used for Sanmopia business files; those stay in Supabase Storage

All services are configured through .env.example; application code must not inline hosts, ports, image names, tokens, or public URLs.

Traffic shaping and telemetry are part of the platform boundary, not feature UI code. Traefik owns public-router rateLimit and inFlightReq middleware for backend, frontend, and Supabase API traffic. OpenTelemetry Collector receives backend OTLP telemetry through the internal network and keeps exporter wiring centralized for later Prometheus, Grafana, Tempo, and Loki adoption.

Detailed rules live in Observability and Backpressure.

Communication delivery uses Novu Event API. The backend adapter sends the stable workflow key, subscriber id, payload, and transactionId derived from the Sanmopia dispatch idempotency key. This keeps retry behavior stable even if the delivery provider receives the same trigger twice.

Strapi authors sanmopia_communication_workflow_rules. The domain still names business events, such as virtual_account_payment_cancelled and daily_report_requested; source notification names remain import-boundary translation only. Default workflow bootstrap rows come from backend communication_workflow_catalog, then sync into Supabase/Strapi and Novu. Do not duplicate workflow keys, channel lists, or policy version labels in Compose, frontend fixtures, or SQL-only seed scripts. Regenerate backend-repo/supabase/seed.sql with uv run python scripts/render_supabase_seed.py --write; backend tests compare the generated SQL to the committed seed file.

SpiceDB owns relationship checks for actor capabilities that change by reservation stage. Supabase RLS still protects table access. Backend policies must check both the Sanmopia stage capability and the SpiceDB relationship where a workflow exposes reservation, charge, document, or settlement data. Reservation booking submission is modeled as the stage capability submit_booking_request; before a Restate booking workflow is started, backend checks draft-stage capability and branch manage permission through SpiceDB.

SpiceDB schema source of truth is backend-repo/src/sanmopia_modernization/application/platform/sanmopia_spicedb_schema.zed. The backend reads the same file for SANMOPIA_SPICEDB_SCHEMA; the stage bootstrap service mounts the backend source volume and runs zed schema write against authorization-dev after authorization-migrate has run spicedb migrate head and Mutagen has synced backend source. Do not duplicate schema text in Compose, docs, or Python constants.

Settlement permissions are actor-kind-specific in SpiceDB. Branch operators can prepare branch settlement through prepare_branch_settlement; HQ reviewers must hold review_hq_settlement. Supabase app metadata must not decide whether a caller may act as branch manager, headquarters administrator, or settlement data-room workflow actor.

OpenFGA is not introduced in the current migration. It overlaps with SpiceDB in the Zanzibar/ReBAC relationship-authorization layer, while OPA covers a different concern: law, status, and request-context policy decisions.

Restate is adopted as the Sanmopia workflow state engine. Long-running state transitions for reservation lifecycle, settlement closeout, and member withdrawal/privacy retention run through Restate workflows so orchestration, retry, and duplicate-command prevention are not reimplemented in Supabase Cron, Queues, or application-only loops.

The local stage runs Restate as workflow-state-dev. Backend code calls the ingress URL from RESTATE_INGRESS_URL; the admin/UI URL is internal unless Traefik is explicitly enabled. Do not inline Restate ports in adapters, contracts, frontend fixtures, or tests. The backend FastAPI app mounts the Restate Python SDK endpoint at /restate/v1. Stage registration uses pnpm workflow:register inside docs-dev, which posts the internal backend deployment URL to the Restate Admin API with HTTP/1.1 enabled for Uvicorn. The deployment exposes MemberPrivacyLifecycleWorkflow, MemberPrivacyPurgeWorkflow, ReservationBookingWorkflow, ReservationFinancialLifecycleWorkflow, and ReservationPaymentWorkflow from the same endpoint so Restate discovers workflow handlers through one internal service URL.

Backend SpiceDB checks use SPICEDB_HTTP_URL on the internal Compose network. Zed schema bootstrap still uses SPICEDB_ENDPOINT for gRPC; application authorization checks do not require host port exposure.

MemberPrivacyLifecycleWorkflow owns withdrawal intake, active-reservation guards, legal-retention classification, masking/anonymization, delayed purge scheduling, retry, and idempotency. Supabase remains the durable business ledger, query model, and RLS boundary. Restate workflows write idempotent command results to Supabase and resume from persisted ledger facts instead of becoming the business source of record. Backend application code talks to the neutral WorkflowRequest, WorkflowInvocation, and WorkflowInvocationPort contract; the Restate HTTP adapter and SDK service mount are the product-specific edges. The registered handler now advances sanmopia_member_privacy_lifecycles through running, operator-review, failed, and completed states, writes statutory retained-record snapshots to sanmopia_member_retained_record_snapshots, schedules purge work in sanmopia_member_privacy_purge_tasks, and masks the Supabase Auth account email/metadata while clearing app roles and applying the withdrawn-user ban duration when operator review is not required. MemberPrivacyPurgeWorkflow claims due purge tasks, redacts retained snapshot payloads, and marks purge ledgers completed or failed with execution summaries. Backend internal jobs can start due purge workflows through POST /internal/member-privacy-purge-workflow-triggers, guarded by SANMOPIA_INTERNAL_JOB_TOKEN, so Cron does not need Supabase service-role table access. Supabase Cron uses pg_cron plus pg_net to call that backend route through sanmopia_trigger_due_member_privacy_purge_workflows_from_cron; stage runtime settings come from pnpm supabase:cron:configure, not inline SQL secrets. Already-issued access JWTs are handled in two places after withdrawal: backend Auth reads reject current sanmopia_privacy_status=withdrawn app-metadata, and Supabase RLS applies a restrictive private.sanmopia_current_member_session_is_active() guard across Sanmopia Data API tables. Remaining privacy work is runtime smoke of Cron request history and stale-token rejection against a live self-hosted Supabase stack. The smoke commands are pnpm supabase:cron:smoke and SANMOPIA_WITHDRAWN_MEMBER_ACCESS_TOKEN=<withdrawn-token> pnpm privacy:stale-token:smoke.

Workflow status display is exposed through a shared backend WorkflowStatusProjection endpoint, not through Restate APIs. Mother, caregiver, branch, and HQ portals poll the backend projection to show public status, durable step, blocked reason, retry/failure state, resulting read-model ids, and currently available actor-stage capabilities. Backend filters the projection by Supabase Auth identity, same-user ownership, reservation stage, SpiceDB relation, and OPA policy so UI code does not depend on Restate internals or service-role Supabase ledger tables. Branch settlement operator dashboards follow the same rule: the backend joins settlement rows with financial lifecycle workflow rows and exposes only public workflow status/id fields through GET /branch-settlement-operator-dashboards/{branchProfileId}. Settlement reconciliation exports reuse that dashboard read model and expose renderer-ready columns, rows, totals, format intent, and private business-report-exports bucket target through GET /branch-settlement-reconciliation-exports/{branchProfileId}. Stored export commands use POST /branch-settlement-reconciliation-export-artifacts/{branchProfileId} to write sanmopia_business_report_snapshots, sanmopia_business_report_exports, and the private Supabase Storage object so later policy or price changes do not mutate already-rendered operator files. Async export starts use POST /branch-settlement-reconciliation-export-workflow-starts/{branchProfileId} to freeze the export dataset before sending it to BranchSettlementReconciliationExportWorkflow in Restate for delayed rendering. The same business-report export row records workflow key, invocation, public status, transition timestamp, and rendered artifact ids, so branch portals poll WorkflowStatusProjection kind branch_settlement_reconciliation_export instead of Restate. Failed or cancelled export workflows can be repaired through backend API commands. Retry sends the stored snapshot payload back to Restate instead of rebuilding current settlement totals; cancellation records a public cancelled state plus repair audit fields in Supabase. Settlement data-room requests sit above those export datasets. The data room tracks external_counterparty_settlement, branch_to_headquarters_settlement, headquarters_settlement, and inter_office_settlement collections, source dataset revision, generated artifacts, supersession, download-link handoff, and repair actions. Supabase Storage remains the artifact store, Supabase Postgres remains the action journal, and Restate remains the async renderer. Workflow actors use /builds and /ready-artifacts; branch manager and headquarters administrator actors use /download-handoffs and /repair-requests with optimistic revision checks. Hidden iframe downloads and status-only PHP pages are not modern UX contracts. Booking projections expose motherUserId and branchProfileId as read-model identifiers only for backend authorization and generated-client routing; UI copy should not display those ids as business facts.

ReservationBookingWorkflow owns the reservation booking saga before the individual domain state changes are committed: mother/branch actor context, candidate caregiver ids, payment method, price lookup keys, branch/caregiver scope, notification trigger, accepted price catalog quote, and charge-finalization handoff. The accepted quote is persisted in the Restate input payload as acceptedPriceCatalogQuote; when present, booking finalizes that immutable quote into sanmopia_finalized_charge_snapshots before payment handoff reads it. The workflow key uses reservationBookingRequestId when supplied, otherwise the member id and reservation start timestamp, so duplicate booking commands do not create a second workflow run. Supabase persists workflow ledger/read-model rows in sanmopia_reservation_booking_workflow_requests with RLS. The row records start metadata, current status, last_transition_at, completed reservation id, finalized_charge_snapshot_id, and failure reason. Restate remains the state engine for retries and handler progress; Supabase is query evidence, not the orchestration loop. The backend command boundary rejects accepted-quote bookings when legacy reservationAmountHint differs from the quote’s customerPayableAmountKrw. That keeps browser card state, old PHP amount hints, and operator projections from becoming a second money authority after a catalog quote has been accepted.

Reservation financial lifecycle read APIs expose finalized charge snapshots instead of recomputing payment display from downstream ledgers. The mother UI reads MotherVisibleChargeSummary through the backend, backed by Supabase snapshot rows. Later caregiver payout, branch settlement, commission, or HQ settlement updates can advance the lifecycle without changing the frozen amount already shown to the mother. Financial lifecycle command APIs now persist the linear downstream stages: service delivered, caregiver payout ready, branch settlement ready, HQ settlement ready, and settled. Supabase stores the immutable charge snapshot in sanmopia_finalized_charge_snapshots and sanmopia_finalized_charge_lines, and stores downstream payout/settlement facts in sanmopia_reservation_financial_lifecycles plus sanmopia_reservation_financial_ledger_lines. Restate should orchestrate long running settlement closeout and retries; it must not replace the Supabase business ledger or RLS read model. ReservationFinancialLifecycleWorkflow starts from POST /reservation-financial-lifecycle-workflow-starts, uses financialLifecycleWorkflowRequestId as the Restate workflow key, and executes the existing financial lifecycle commands in order. Progress reads use GET /reservation-financial-lifecycle-workflow-requests/{financialLifecycleWorkflowRequestId}. Supabase stores the backend-only workflow ledger/read model in sanmopia_reservation_financial_lifecycle_workflow_requests, including stage totals, last_transition_at, and failure reason. Operators may still use the direct command APIs for controlled single-stage repair, but normal closeout automation should go through Restate.

ReservationPaymentWorkflow owns Kill Bill billing operations that must survive provider timeouts and duplicate submission: paid-payment verification, payment cancel, and refund. It uses paymentWorkflowRequestId as the Restate workflow key. The Kill Bill REST HTTP adapter verifies paid state through the payment lookup API before reservation completion and sends cancel/refund requests with idempotency keys. Payment UI entry is protected before the workflow starts by POST /reservation-payment-pre-registrations, which calls Kill Bill REST /payments/{paymentId}/pre-register and stores the local waiting payment fact in sanmopia_payment_records without allowing the same paymentId to be silently reused for another amount. Public payment workflow orchestration starts through POST /reservation-payment-workflow-starts and progress reads through GET /reservation-payment-workflow-requests/{paymentWorkflowRequestId}. Supabase stores the backend-only workflow ledger in sanmopia_reservation_payment_workflow_requests; authenticated clients do not read or write it through the Data API directly. Kill Bill payment event callback acceptance enters through POST /killbill-payment-events as the official generic ExtBusEvent. Payment semantics come from PAYMENT_SUCCESS or PAYMENT_FAILED, objectType=PAYMENT, objectId, tenantId, and JSON metaData, especially transactionType, status, paymentTransactionId, and effectiveDate. PURCHASE/CAPTURE, REFUND, and VOID operations must not be represented as invented event names. Kill Bill push notifications do not provide a signature header. The configured webhook secret is an internal callback-admission token that a restricted Traefik callback route must inject; it is not provider signature evidence. One Supabase RPC atomically inserts the immutable inbox row and sends the sanmopia_payment_webhook_processing PGMQ message before 202. Canonical replays return duplicate without another message. Same event identity with a different canonical digest returns quarantined, preserves both payload digests plus incoming payload in a repair row, and applies no business effect. Stage proof pnpm supabase:smoke:stage:killbill-payment-event-callback posts the official ExtBus payment payload shape through the hosted backend, verifies the inbox row, verifies one PGMQ callback message, verifies duplicate replay without requeue, dispatches the payment_webhook_processing worker, and verifies the inbox moves through Restate to a completed payment workflow and an approved payment record. pnpm supabase:smoke:stage:killbill-callback-to-settlement-completion then uses that callback-approved payment workflow as the settlement continuation source and proves service delivery, caregiver payout, branch settlement, headquarters settlement, and final settlement completion without starting a second payment workflow. Real provider-originated callback E2E remains incomplete until the trusted route is deployed. Operator support operations that used to require PG/KCP console access now enter through POST /killbill-payment-admin-operations; this route keeps provider lookup, search, virtual-account close, refund/chargeback inspection, and chargeback recording behind Supabase Auth, SpiceDB branch authorization, idempotency fingerprints, and service-role-only sanmopia_killbill_admin_operation_audits. Successful paid-payment verification hands off to approved charge finalization, validates the approved amount against the immutable finalized charge snapshot, and records approved_charge_finalization_status plus finalized_charge_snapshot_id in sanmopia_reservation_payment_workflow_requests. Paid webhooks persist payment_closeout_kind, so vcnt records are traceable as virtual_account_deposit instead of being inferred from provider event text. Approved payment closeout then asks the neutral workflow engine to continue settlement through ReservationFinancialLifecycleWorkflow when prepared source facts exist. The payment workflow ledger records settlement_continuation_status, settlement_continuation_workflow_request_id, and settlement_continuation_reason; missing service-delivery, caregiver payout, branch settlement, or HQ ledger facts stay explicit as waiting_for_financial_facts instead of hidden retries or hardcoded amounts. The Supabase continuation fact reader maps prepared service delivery, caregiver payout, branch settlement, and HQ settlement ledger rows into the workflow payload. Stored export artifact rendering and manual repair UX remain migration backlog items.

ReservationOperationFollowUpWorkflow owns branch/HQ operation-plan follow-up work that should not block the command response: gift coupon issuance, other-area refresh, cancellation notices, extension payment requests, branch settlement opening, financial closeout, virtual-account deposit closeout, caregiver assignment, service delivery, daily report, service-balance receipt, and price-version changes. Restate executes the workflow; Supabase persists service-role-only request/action ledgers; the portal reads only the backend WorkflowStatusProjection kind reservation_operation_follow_up.

OPA owns policy-as-code for legal basis, reservation/settlement state, actor context, and data-minimization decisions. SpiceDB still answers relationship authorization questions, such as whether an actor can view or operate on a reservation, branch, document, or settlement. Backend commands must pass both policy and relationship checks before reading, masking, exporting, or retaining personal data.

Withdrawal/privacy policy baseline:

  • 개인정보 보호법 제21조: personal data is destroyed when retention period expires or processing purpose is achieved, unless another statute requires retention; retained personal data must be stored and managed separately.
  • 전자상거래 등에서의 소비자보호에 관한 법률 시행령 제6조: display/advertising records are retained for 6 months; contract or subscription-withdrawal records and payment/supply records for 5 years; consumer complaint/dispute records for 3 years.
  • 국세기본법 제85조의3: tax books and evidence documents are retained for 5 years from the statutory filing deadline for the relevant tax period.
  • 사회서비스 제공자료: service provision contracts, provision/cost-claim records, and user-borne cost records are retained for 5 years from the service date.

Strapi replaces handmade admin CRUD for content, campaigns, notification rules, child vaccination schedule policies, mobile app entry points, document definitions, daily report definitions, promotion rules, caregiver recognition seasons, metric rules, caregiver grade thresholds, branch office notices, caregiver education material, health-education profiles, articles, videos, FAQ entries, visit classes, branch stamp/profile assets, and document-room posts. Public app reads still go through Sanmopia APIs and Supabase policies, not Strapi APIs.

Document templates and generated artifacts stay in private Supabase Storage. Strapi can author template metadata, but download handoff and signing remain backend-owned so authorization is not bypassed. Document-room attachments use a separate private Supabase Storage bucket, document-room-attachments, and must have parent post metadata before a signed user can read the object.

Child-health guidance uses Strapi for versioned schedule rows and app-entry destinations, while backend domain code owns eligibility and date-window calculation. Backend policy work uses python-dateutil; frontend schedule preview uses date-fns for month/day arithmetic and links to KDCA official schedule lookup instead of becoming a medical source of truth. App-open routing uses native browser navigation because a device-detection package would not remove enough LOC or maintenance risk.

Branch office content uses Strapi for branch/HQ notices, caregiver instructions, education material, and media authoring. Supabase keeps the business rows and private content-assets objects; branch-scoped RLS allows HQ or target branch members to read rows instead of trusting a frontend route guard.

Reservation date planning uses Sanmopia domain rules, but national Korean public holiday seed data comes from the OSS holidays Python package. The package is used only at the application/provider boundary to create ServiceCalendarHolidayPayload rows; domain policy consumes explicit holiday facts and never imports the package.

Supabase stores national and branch-scoped holiday rows in sanmopia_service_calendar_holidays, selected reservation extra-service dates in sanmopia_reservation_extra_service_days, and captured plan results in sanmopia_reservation_service_calendar_plans. Branch closures and exceptional service days remain explicit policy rows because regional operating policy is business-owned and can change independently of the public holiday provider. Holiday publication and branch override audit evidence is stored separately in sanmopia_service_calendar_holiday_audits and sanmopia_service_calendar_holiday_audit_revisions, so operator edits do not mutate the effective calendar without append-only actor, reason, expected revision, provider fingerprint, and idempotency proof.

Price workbook ingestion uses OSS openpyxl at the adapter boundary, with defusedxml installed for XML hardening. Domain code never imports workbook libraries; it receives componentized PriceCatalogEntry rows with flexible charge:<code>:<role>:<basis> and compensation:<code>:<role>:<basis> components. The voucher-care workbook parser maps the 2025 value sheet into normalized entries and keeps all cell-position knowledge outside the domain. Workbook audit results are source-specific: 2025_voucher_250121 has 190 entries and 8 issues, 2025_voucher_250827 has 190 and 8, 2026_voucher_251211 has 154 and 0, and 2026_voucher_260119 has 143 and 55. An older issue-free workbook is never selected as implicit current authority; publication requires an explicitly approved source and version.

Supabase Storage keeps uploaded workbook artifacts in the private price-catalog-imports bucket. Supabase Postgres stores import lifecycle rows in sanmopia_price_catalog_import_batches and immutable componentized rows in sanmopia_price_catalog_entries. This replaces PHP CLI spreadsheet mutation with versioned imports, issue reporting, and published catalog snapshots that quote and settlement readers can copy into finalized charge snapshots. Completed voucher-plan source facts live in sanmopia_voucher_plan_population_source_revisions and ordered sanmopia_voucher_plan_population_source_slices. One atomic publication records sanmopia_voucher_plan_catalog_populations and links every generated revision to its committed population_key; partial publication rolls back. Exact reads match service-detail, baby, delivery, consume-type axes and quote date against committed populations only. They never choose a caller-supplied revision, batch, version, or latest fallback. Backend 7a9d26e1c1c53b928e14504cfd37152294f9a31f adds the protected HQ approval-to-population entrypoint. A bearer must resolve to an active Supabase session actor, and SpiceDB must grant that actor manage on hq_settlement:price_catalog_management. Caller-supplied actor, approval time, or source-manifest facts are rejected: PostgreSQL stamps approved_at on first success and returns that stamp on replay. The database derives canonical manifest SHA-256 from the frozen source header, ordered slices, batch/workbook, and normalized entry fingerprint, then requires exact workbook, catalog/version, and entry-set equality. Legacy unapproved completion/publication service-role execution is revoked; only approved RPCs remain executable. Existing unapproved populations are preserved but quarantined from resolve and new publication. Immutability plus insert and deferred linkage guards prevent lineage drift.

No real workbook/source/version was selected, approved, or invoked. The 154 / 0 audit result is not automatic source authority. This backend-only increment had no stage/online deployment or browser run. PriceCatalogQuote reads published/effective entries through the Supabase repository and returns componentized lines with explicit basis and quantity. The quote contract keeps gross_service_price separate from customer_share so mother-facing payable totals do not double-count workbook rows where the customer share is already post-subsidy. When a quote is accepted, backend finalization writes immutable snapshot rows to sanmopia_finalized_charge_snapshots and sanmopia_finalized_charge_lines. Neutral catalog facts use reference direction, so retained gross/subsidy evidence never changes payable or settlement totals after payment close.

Statistics and tabular admin reports use backend-owned business-report snapshots instead of rebuilding source table controllers. Supabase stores the canonical rows in sanmopia_business_report_snapshots; export requests live in sanmopia_business_report_exports; generated CSV, XLSX, or JSON artifacts live in the private business-report-exports bucket. Branch settlement reconciliation now uses the same snapshot/export tables before storage upload, and its Restate workflow receives a frozen export dataset, so operator-visible numbers stay stable after later pricing or policy changes.

Frontend report tables should use OSS table tooling such as TanStack Table on top of Sanmopia contracts. If ad hoc client-side shaping is needed, prefer a small OSS data engine such as Arquero or DuckDB-Wasm after measuring bundle impact. If browser-side XLSX generation is required, use SheetJS through pnpm; otherwise backend-generated artifacts should be read from Supabase Storage. Do not reintroduce jQuery DataTables request contracts, hand-built spreadsheet controllers, or hardcoded report columns in Astro pages.