MSO Cloud · Documentation

MSO Cloud — Kiến trúc (chi tiết)

Source: docs/architecture/reference/architecture-detail.md Updated 2026-09-21
On this page

Đi qua bảy tầng L0–L6 với cơ chế thật. Hình dạng container — hai process, các store, danh sách package — nằm ở System Architecture; trang này chỉ ghi cơ chế của từng tầng.

L0 — Nền tảng#

Ba ràng buộc nền mà mọi tầng bên trên dựa vào.

Web KHÔNG publish BullMQ trực tiếp — business row và intent async commit chung một transaction qua outbox (xem L1). Các identity runtime riêng, không bypass RLS; ACL exact/default-deny sinh từ packages/schema/src/runtime-acl.ts.

L1 — Tenancy: org → brand#

Hai cấp:

  • Org = một khách hàng. FORCE RLS cưỡng chế bảng org-scoped qua GUC app.current_org_id; mọi bảng được inventory hoặc nằm trong exception registry có threat model.
  • Brand = phân vùng workspace trong org. Bảng fact/asset mang cả org_idbrand_id; router brand-scoped pin thêm app.current_brand_id. Ingest là brand-bound và shop-owned.
  • User thuộc org qua org_memberships; viewable_brand_ids quyết định brand chuyển vào được — authz brand ở tầng app, RLS là lưới an toàn.

Cơ chế pin: runWithRlsContext(orgId, brandId, fn) (packages/schema/src/rls-context.ts) mở transaction, set_config(..., is_local=true) cả hai GUC, rồi chạy resolver trên đúng connection đó. orgProcedure / brandProcedure (apps/web/src/server/trpc.ts) bọc TOÀN BỘ resolver. Một set_config transaction-local đứng rời ngoài transaction là no-op dưới postgres.js pool.

Host → org: getRequestTenant() pin org theo subdomain / white-label domain (*.msocloud.net); URL phụ thuộc host xây qua requestBaseUrl(), không bao giờ NEXTAUTH_URL trần. Credential connector envelope-encrypted (@yng/crypto), mỗi write đóng dấu keyVersion.

Công việc bền dùng transactional outbox: business row + job_outbox commit chung transaction; dispatcher phía worker lease → publish → retry idempotent, rồi worker mới xử lý. Web không dùng deferAfterCommit cho durable work, và không có network I/O dài (LLM/HTTP/Redis) bên trong scope RLS.

flowchart TB
  REQ["Request (browser / API)"]
  HOST["getRequestTenant()<br/>host → org pin"]
  PROC["orgProcedure / brandProcedure<br/>(server/trpc.ts)"]
  RLS["runWithRlsContext(orgId, brandId)<br/>SET LOCAL app.current_org_id / current_brand_id"]
  PG[("Postgres 16 · FORCE RLS<br/>identity riêng · NOBYPASSRLS")]

  REQ --> HOST --> PROC --> RLS --> PG

  subgraph OUTBOX["Công việc bền — transactional outbox"]
    BROW["business row + job_outbox<br/>(commit chung 1 transaction)"]
    DISP["dispatcher<br/>lease → publish → retry idempotent"]
    BULL["BullMQ (Redis)"]
    WRK["worker · identity riêng"]
    BROW --> DISP --> BULL --> WRK
  end
  RLS -->|"enqueueDurableJob"| BROW

RLS là lưới an toàn cho code tin cậy, KHÔNG phải containment khi runtime credential bị đánh cắp — authorization thật sự nằm ở tầng app.

L2 — Connector family = data plane#

Bốn family, mỗi family gắn một plane (packages/connectors/core/src/mapping/starter-templates.ts):

  • marketplace — 1st-party TRUTH (sàn TMĐT + upload seller export).
  • marketing — 1st-party.
  • market_intel — 3rd-party ESTIMATE.
  • content_intel — 1st-party, pillar audience.

Transport: api (pull theo lịch), file (upload CSV/XLSX), sheet (range binding, tối đa 20). Mapping spec là DATA (import_mapping_templates), không code — không if (sourceId === …); tập transform đóng, template active bất biến. XLSX của sàn hay chèn sheet pivot ở đầu → luôn decode sheet lớn nhất, không SheetNames[0].

Ước lượng KHÔNG đi qua bronze: cả upload lẫn Open API hội tụ về MỘT persister persistMarketIntelSnapshot, supersede theo natural key, provenance ở header snapshot.

flowchart TB
  subgraph FAM["Bốn connector family"]
    F1["marketplace<br/>1st-party TRUTH"]:::first
    F2["marketing<br/>1st-party"]:::first
    F4["content_intel<br/>1st-party · audience"]:::first
    F3["market_intel<br/>3rd-party ESTIMATE"]:::est
  end
  subgraph TR["Transport"]
    API["api (pull)"]
    FILE["file (CSV/XLSX)"]
    SHEET["sheet (bindings, ≤20)"]
  end
  MAP["Mapping templates — DATA<br/>immutable-once-active"]
  RAW["bronze: raw_events<br/>orders/creators/inventory"]
  STG["bronze: import_staging_rows"]
  SNAP["persistMarketIntelSnapshot<br/>(bỏ qua bronze)"]
  PROM["Promoters → silver → gold"]
  SERVE["MI surfaces (ngoài verify:data)"]:::est

  F1 --> TR
  F2 --> TR
  F4 --> TR
  TR --> MAP
  MAP --> RAW
  MAP --> STG
  RAW --> PROM
  STG --> PROM
  F3 -->|"upload + Open API"| SNAP
  SNAP --> SERVE

  classDef first fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f;
  classDef est fill:#fef3c7,stroke:#f59e0b,color:#92400e;

L3 — Kho medallion (1st-party)#

Cơ chế bronze → silver → gold, watermark, thư chết và outbox nằm ở Ingest pipeline. Ở tầng này chỉ ghi phần bất biến mà mọi con số phải tuân.

Bất biến DATA sống ở đây: VND số nguyên; date axes ICT-bucketed + ::text-projected + ICT-anchored filter cả ba (cạnh ngày dựng ở UTC kéo nhầm ngày tháng bên cạnh, lỗi đã ship >10 lần); một stream sync có metric phải chạm rollup, không chỉ router hiển thị. Mọi bề mặt có số có một assertion đối chiếu trong data-reconciliation.ts (verify:data), EXPECTED neo vào oracle NGOÀI code: headline == SUM(rows hiển thị), rolling == period, không double-count khi merge.

L4 — Lớp semantic (Cube)#

Một định nghĩa metric phục vụ UI, AI và cảnh báo — không có bản sao. Schema Cube SINH từ catalog METRIC_DEFINITIONS (packages/metrics/src/catalog.ts) qua scripts/cube/generate-schema.tsinfra/cube/schema/__generated__ + view Metrics; không sửa tay (inv 9, guard cube:generate:check). Tool queryCube đối chiếu mọi member với /meta live trước khi chạy.

Tenant isolation ở Cube: JWT checkAuthqueryRewrite từ chối query thiếu orgId → driver pin GUC per-connection; role DB riêng cho Cube (NOSUPERUSER, NOBYPASSRLS); verifier scripts/cube/verify-tenant-isolation.ts. Cube không mang brand_id (bốn bảng nguồn thiếu cột) → khi org có >1 brand và đang pin brand, resolver ABSTAIN (mode:'unavailable'), không trả số org-wide âm thầm.

L5 — Lớp thông minh#

Decision engine (MSO off-take loop)

Vòng học 2 lớp per-brand:

  • Lớp 1 — chọn hành động bằng SỐ, không semantic: chọn lever bằng max partial-Spearman CÓ DẤU giữa lever[t−1]GMV[t] (khử lịch campaign), kèm explore/exploit tất định. Chọn playbook = predicate số trên Rule DSL (playbook-rules.ts); văn bản whenToUse/matchedReasons chỉ là BIÊN NHẬN, không tham gia tính toán.
  • Lớp 2 — học thống kê: module PURE packages/metrics/src/offtake.ts (không DB, không Date.now): winsorize → partialSpearman → OLS (Householder QR) → walk-forward out-of-sample → guardrails (OEC + trần giảm giá 18% + abstain) → verdict. Tương quan lưu bằng basis points có dấu (×10000), không float.
  • Abstain là mặc định: <20 tuần → learning; |corr| < 0.30 → no_reliable_signal. Không bao giờ fabricate lift.
  • Persist DUY NHẤT qua offtake-loop.ts (upsert idempotent theo natural key, pin RLS trong transaction). Đo kết quả CHỈ trên orders; track record chỉ đếm verdict owner-confirm. Cron: offtake:weekly-fit / weekly-review / validation-ping-daily (giờ ICT).

Vòng đời quyết định: proposed → approved → activated → measured → confirmed_* (hoặc rejected / abstained). Approve ≠ execute; mọi mutation gate requireOwnerOrOrgAdmin + rate-limit + writeAuditLog.

AI (@yng/ai)

Không bao giờ call provider trực tiếp. Cấu hình sống ở ai_connections (nhiều row/org: provider, models[] CÓ THỨ TỰ, key envelope-encrypted, priority):

  • Một resolver (resolveAiCandidates / resolveOrgAi) đọc → decrypt → map provider → candidate có thứ tự.
  • Một failover primitive (createFailoverLanguageModel) bọc candidates thành một LanguageModelV3; lỗi retryable → nhảy candidate kế; doStream peek-then-commit — chỉ failover TRƯỚC token nội dung đầu tiên.
  • Model ẩn với end user — chỉ render ở /admin/ai + /settings/ai; completion non-streaming phải qua truncation guard trước khi publish/cache.

Morning Brief

Digest hằng ngày/tuần/tháng cho org admin, tuỳ org type, gated theo pillar. Dùng chung grammar Rule DSL của decision engine để sinh notification (không phải playbook) — không có ngôn ngữ predicate thứ hai.

Thông báo

Hai registry, một đường gửi (ADR 0046/0047/0050).

  • Kênh là registry, không phải union viết tay. packages/notify/src/channels.ts khai báo mỗi kênh một dòng (id, kind, userLink, khoá i18n, nhãn mặc định); mọi thứ khác suy ra từ đó — union id, boundary Zod, tập kênh ngoài, câu hỏi "kênh này người dùng phải tự liên kết không". Module này thuần và chạy được ở browser vì ma trận tuỳ chọn là client component (§73).
  • Mỗi kênh một adapter (packages/notify-channels/src/adapters), soi theo hợp đồng connector (ADR 0049): schema config (Zod có tên) + probeConfig, publicMeta, resolveUserTargets, revokeTarget?, deliver, testSend. Map adapter có kiểu Record<ExternalNotificationChannel, …> nên một dòng registry thiếu adapter là lỗi biên dịch. sendThroughChannel và đường gửi của alert engine không còn nhánh theo kênh. Thêm kênh mới = một dòng registry + một adapter.
  • Ba tầng gửi, không đổi: nền tảng cấu hình kênh (notification_channel_configs) → tổ chức được phép (org_notification_channels, mặc định TẮT) → người nhận chọn theo từng loại và từng brand (notification_preferences, mặc định chỉ in-app). in-app miễn tầng nền tảng; adapter chỉ nhận config và target sau khi đủ ba tầng, không nới được tầng nào.
  • Loại thông báo do domain khai báo. Một dòng verticals.ts mang notificationTypes cạnh briefPersonas/levers/packs; loại thuộc nền tảng (§79) và loại không thuộc vertical nào (dataset.rule_fired, brief.ready) ở lại NOTIFICATION_BASE_TYPES. NOTIFICATION_TYPES là hợp của hai nguồn và KHÔNG theo org — đường ghi inbox phải phân giải được mọi key, nếu không một phát hiện thật sẽ mất tăm.
  • Hai cổng khi ĐỀ NGHỊ một loại cho org (notificationTypesForOrg + filter của caller): domain có được bật cho org đó (org_domain_activations) VÀ org có dữ liệu trên plane đó (resolveOrgCapabilities(orgId,'data')). Cổng thứ nhất hỏi org được cấp hình gì, cổng thứ hai hỏi org thực sự có dòng dữ liệu nào — phải qua cả hai.

L6 — Presentation#

  • Dashboard RSC đọc gold qua tRPC; analytics ad-hoc nặng qua Cube.
  • Plugin & entitlement: manifest tĩnh (packages/plugin-host/src/registry.ts); bật/tắt per-org qua org_plugin_installations. Mỗi page mở đầu await requirePlugin('<id>') — super-admin bypass, org chưa bật → redirect; ẩn nav KHÔNG phải enforcement (bypass bằng URL trực tiếp). Entitlement ở tầng PAGE + NAV, không phải tRPC (ngoại lệ chat-with-data gate ngay route).
  • Content Intelligence decks (pillar audience) — presentation là DATA, taxonomy là catalog DATA, zero code per-client.
  • Copy qua @yng/i18n (vi là nguồn), noun phrase tiếng Việt, không tên vendor / jargon; tiền/ngày/percent qua formatter chung.

Vòng đời request

sequenceDiagram
  autonumber
  participant B as Browser
  participant N as RSC / tRPC
  participant A as Auth
  participant R as RLS scope
  participant D as Postgres
  B->>N: request
  N->>A: session (1 lần/request)
  A-->>N: org + brand
  N->>R: pin GUCs (SET LOCAL)
  R->>D: query (FORCE RLS)
  D-->>R: rows
  R-->>N: result
  N-->>B: RSC / JSON

Biến thể /api/chat đi cùng đường: auth + plugin gate, pin RLS, rồi resolveOrgAi dựng chuỗi failover; mỗi tool call re-pin RLS và gate market tools; stream chỉ failover trước token đầu tiên.

Khi Market Intelligence TẮT, Data Assistant KHÔNG chạm dữ liệu thị trường 3rd — market tools chỉ vào chat khi ctx.marketEnabled, /api/chat assert zero market key khi tắt (fail-closed).

Observability#

Structured logs (job id, connector, org id, duration, số row); audit trail audit_logs append-only (UPDATE bị RLS chặn); dead-letter queue sync_dead_letters tự lành; DSAR export org-admin gate.


Thay đổi mang tính kiến trúc đi kèm một ADR mới.

Liên quan#