OSS service stack
Sanmopia uses OSS services where they remove generic platform work without leaking vendor-shaped models into the domain.
Stage services
Section titled “Stage services”| Service | OSS | Owned capability | Sanmopia boundary |
|---|---|---|---|
cms-dev | Strapi | Admin authoring, media library, editorial CRUD | Backend owns publication lifecycle, RLS, and public contracts |
novu-api / novu-worker / novu-ws / novu-dashboard | Novu | Workflow execution, subscriber inbox, provider routing | Backend owns communication triggers, dispatch idempotency, and audit |
workflow-state-dev | Restate | Durable workflow state engine, retry, idempotent command orchestration | Backend starts Sanmopia workflows; Supabase remains the ledger, read model, and RLS store |
otel-collector | OpenTelemetry Collector | Vendor-neutral telemetry receiver, processor, exporter, internal collector metrics | Backend emits OTLP; domain/application modules stay telemetry-product-free |
authorization-migrate + authorization-dev + authorization-schema-bootstrap | SpiceDB + Zed | Relationship permission graph, datastore migration, and schema bootstrap | Supabase stores business rows; backend maps reservation-stage capabilities |
policy-dev | OPA | Law, state, and context policy-as-code | Backend evaluates policy gates before state transitions; complements SpiceDB and Supabase RLS |
| Supabase stack | Supabase | Postgres, Auth, Storage, Realtime, Cron, Queues, Vector | Domain ports decide when platform services are used |
| Backend package dependency | OR-Tools CP-SAT | Branch-wide assignment optimization | Adapter solves caregiver assignment; domain keeps solver-free assignment facts and ports |
novu-localstack | LocalStack | S3-compatible Novu development storage | Not 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.
Observability And Backpressure
Section titled “Observability And Backpressure”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
Section titled “Communication delivery”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.
Authorization
Section titled “Authorization”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.
Workflow state and privacy lifecycle
Section titled “Workflow state and privacy lifecycle”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.
CMS and documents
Section titled “CMS and documents”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.
Service calendar
Section titled “Service calendar”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 catalog import
Section titled “Price catalog import”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.
Business reporting
Section titled “Business reporting”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.