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_idVÀbrand_id; router brand-scoped pin thêmapp.current_brand_id. Ingest là brand-bound và shop-owned. - User thuộc org qua
org_memberships;viewable_brand_idsquyế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"| BROWRLS 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, pillaraudience.
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.ts → infra/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 checkAuth → queryRewrite 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]và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ảnwhenToUse/matchedReasonschỉ 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ôngDate.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ênorders; 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ộtLanguageModelV3; lỗi retryable → nhảy candidate kế;doStreampeek-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.tskhai 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ểuRecord<ExternalNotificationChannel, …>nên một dòng registry thiếu adapter là lỗi biên dịch.sendThroughChannelvà đườ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-appmiễ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.tsmangnotificationTypescạnhbriefPersonas/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ạiNOTIFICATION_BASE_TYPES.NOTIFICATION_TYPESlà 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 quaorg_plugin_installations. Mỗi page mở đầuawait 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-datagate ngay route). - Content Intelligence decks (pillar
audience) — presentation là DATA, taxonomy là catalog DATA, zero code per-client. - Copy qua
@yng/i18n(vilà 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.