KB-B791

23-P3D4C0X — Universal PG-to-Outside Signal Architecture (Design Note rev1)

39 min read Revision 1
p3d4c0xdesignarchitectureuniversaleventoutboxnotification5-layerrev1

23-P3D4C0X — Universal PG-to-Outside Signal Architecture (Design Note rev1)

Date: 2026-05-08 Status: DESIGN rev1 — read-only architecture review. NO implementation. NO mutation. Prompt: knowledge/dev/laws/dieu44-trien-khai/prompts/23-p3d4c0x-universal-event-outbox-notification-architecture-review-prompt.md (rev2) Report: knowledge/dev/laws/dieu44-trien-khai/reports/23-p3d4c0x-universal-event-outbox-notification-architecture-review-report.md Reviewer: Claude Opus 4.7 (1M context) Scope: KB design note + report only. Không PG/Directus/Nuxt mutation. P3D4C1 vẫn pause.


§0. Tóm tắt điều hành

Thiết kế cơ chế Universal Event/Outbox + Notification/Signal Pipeline trên PG để mọi domain (IU, Birth Registry, Governance, TAC, KG, System/Đ43, DOT, Health) có thể phát tín hiệu ra ngoài (Directus → Nuxt, Agent SQL, future webhook/email) qua một hạ tầng chung 5 lớp, tận dụng tối đa PG-native (trigger, function, view, pg_cron, LISTEN/NOTIFY).

IU notification runtime hiện hành (P3D1+P3D2 ACTIVE)iu_notification_event, iu_notification_read, fn_iu_unread, fn_iu_mark_read, fn_iu_notification_board, 3 triggers — không phá vỡ. Khuyến nghị: UNIVERSAL_WITH_IU_COMPAT (chi tiết §O).

Boundary cứng: Universal event KHÔNG phải general activity log. Chỉ ghi governance-significant facts (entity birth/lifecycle/approval/alert/DOT result) — không ghi mỗi row change, không telemetry/metrics, không debug log.


§A. Domain × Event Type × Actor × Frequency Matrix (Step 0)

Dữ liệu trích từ 13 luật + Pack 23/P3D + Đ43 context-pack runtime. Frequency là order-of-magnitude ước tính dùng cho sizing, không SLA.

# Domain Event Type Event Stream Subject Table Actor (producer) Recipient (default) Phase 1? Frequency
1 iu comment_added comment unit_edit_comment author_ref reviewer + creator ✅ ACTIVE ~10s/day
2 iu draft_created review unit_edit_draft created_by reviewer pool ✅ ACTIVE ~10s/day
3 iu version_applied update unit_version created_by watchers + creator ✅ ACTIVE ~10s/day
4 iu new_piece_created update unit_version (seq=1) created_by watchers (debounced) ⏳ P3D4C1 paused bursty (import)
5 iu document_imported update unit_version (rollup) worker watchers ⏳ P3D4C1 paused per import job
6 birth_registry entity_born birth (any registered table) created_by governance owner Phase 2 irregular
7 birth_registry conformance_warning alert birth_registry rows gate trigger owner + reviewer Phase 2 irregular
8 governance checkpoint_due task governance_checkpoint gov system agency owner Phase 2 scheduled
9 governance approval_requested review approval_request requester approver pool Phase 2 irregular
10 governance approval_decided update approval_decision approver requester + watchers Phase 2 irregular
11 tac text_unit_birth birth normative_registry created_by gov owner Phase 2 low (laws)
12 tac review_pending review text_review_request requester reviewer Phase 2 irregular
13 tac indexing_complete update tac_index_log indexer owner Phase 3 per ingest
14 kg provenance_change update kg_edge edge writer owner Phase 3 bursty
15 kg constraint_violation alert kg_constraint_log constraint engine owner Phase 3 irregular
16 system context_pack_built update context_pack_request dot-context-pack-build sysop Phase 2 (Đ43 native) every 3h cron
17 system context_pack_failed alert context_pack_request builder sysop Phase 2 rare
18 system red_zone_violation alert system_issues health check sysop + agency Phase 2 irregular
19 dot dot_executed update dot_execution_log dot sysop Phase 2 per DOT run
20 dot dot_failed alert dot_execution_log dot sysop + agency Phase 2 rare
21 dot dot_apr_required review admin_fallback_log dot-apr-execute agency reviewer Phase 2 rare
22 health health_check_alert alert system_health_checks health executor sysop + owner Phase 2 per finding
23 health issue_opened alert system_issues (Đ22) health/triage owner agency Phase 2 irregular
24 health issue_resolved update system_issues resolver reporters + watchers Phase 2 irregular

Stream taxonomy chốt: comment | review | update | birth | task | alert | health (7 streams). Đủ phủ các domain trên. Mỗi event_type map cứng vào 1 stream (CHECK constraint cặp).

Total Phase 1 (already ACTIVE): 3 event types (rows 1-3) — IU only. Total Phase 2 (proposed expansion): +18 types across 6 domains. Total Phase 3 (future): +3 types (KG provenance, TAC indexing).


§B. 5-Layer Pipeline Design

┌─────────────────────────────────────────────────────────────────┐
│ Layer 1 — FACT CAPTURE (hot path, O(1))                          │
│ AFTER INSERT trigger → INSERT INTO {domain}_event_pending        │
│ KHÔNG: COUNT/JOIN/aggregation. CHỈ: 1 INSERT + 1 PK lookup.       │
└──────────────────────────────┬──────────────────────────────────┘
                               │ pending row + stable_key
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 2 — PROCESSING (worker, deferred)                          │
│ pg_cron worker tick (90-120s)                                    │
│ - pg_try_advisory_lock(hashtext('worker_name'))                  │
│ - SELECT … FOR UPDATE SKIP LOCKED  (debounce window passed)      │
│ - Group by stable_key (source_doc/import_batch/run_id/…)         │
│ - Emit durable events                                            │
│ - exception-safe: log error, release lock                        │
└──────────────────────────────┬──────────────────────────────────┘
                               │ durable event row
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 3 — DURABLE EVENT + READ STATE                             │
│ event_outbox  (universal)                                         │
│ event_read    (per-actor read state)                              │
│ Implicit self-read | Explicit read | Unread                      │
│ ON DELETE CASCADE (read → event)                                 │
└──────────────────────────────┬──────────────────────────────────┘
                               │ event + read state
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 4 — ROUTING / RECIPIENT                                    │
│ event_subscription  (config: who subscribes to what)             │
│ v_event_recipient   (view: domain × event_type → actor list)     │
│ event_delivery      (per-recipient state — Phase 2)              │
└──────────────────────────────┬──────────────────────────────────┘
                               │ recipient list
                               ▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 5 — EXPOSURE / DELIVERY                                    │
│ • PG view → Directus collection (auto-discover)                  │
│ • SQL function fn_*_unread, fn_*_board (Agent inbox)             │
│ • Nuxt → reads Directus only (Assembly First, no PG direct)      │
│ • LISTEN/NOTIFY (Phase 2 evaluate — real-time Agent push)        │
│ • Webhook/email/chat adapters (Phase 3+ — design seam only)      │
└─────────────────────────────────────────────────────────────────┘

Computation placement (cấm trộn lớp):

Computation Layer
Trigger check WHEN L1
Single column lookup (canonical_address) L1
COUNT/aggregate, JOIN multi-table L2
Debounce window L2
Group by stable_key L2
Idempotency ON CONFLICT DO NOTHING L1 (event already exists) hoặc L3 (durable)
Read state insert L3 (qua fn_event_mark_read)
Recipient resolution L4 (view)
Filter actionable (draft.status='open') L4 (LEFT JOIN trong fn_unread)
UI assembly L5 (Directus/Nuxt)

§C. Event Envelope Contract

Đã chuẩn hoá từ rev2 prompt + IU schema P3D1. Một event = một row trong event_outbox.

-- Universal event envelope (Phase 2 target schema)
CREATE TABLE event_outbox (
  -- Identity
  event_id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),

  -- Domain & taxonomy
  event_domain      text NOT NULL,    -- 'iu','birth_registry','governance','tac','kg','system','dot','health'
  event_type        text NOT NULL,    -- e.g. 'draft_created','dot_failed','health_check_alert'
  event_stream      text NOT NULL,    -- 'comment','review','update','birth','task','alert','health'
  event_severity    text,             -- 'info','warning','critical' — optional, alert/health only

  -- Subject (entity that the event is about)
  event_subject_type   text NOT NULL, -- entity species ('information_unit','dot_execution_log',...)
  event_subject_ref    uuid,          -- entity FK (if uuid PK)
  event_subject_table  text NOT NULL, -- source PG table (for FK-less polymorphic events)
  canonical_address    text NOT NULL CHECK (btrim(canonical_address) != ''),

  -- Actor (who caused / who fired)
  actor_ref         text NOT NULL CHECK (btrim(actor_ref) != ''),
  source_system     text NOT NULL,    -- 'trigger','worker','function','dot','health_executor'
  source_function   text,             -- pg_proc name producing this event

  -- Correlation (link related events)
  correlation_id    text,             -- e.g. import_batch_ref, dot_run_id, build_id, session_id
  causation_id      uuid,             -- event_id that caused this event

  -- Provenance (where the underlying fact came from)
  source_document_ref  text,
  import_batch_ref     text,

  -- Payload — METADATA ONLY
  payload_classification text NOT NULL DEFAULT 'safe_metadata'
    CHECK (payload_classification IN ('safe_metadata','restricted')),
  safe_payload      jsonb NOT NULL DEFAULT '{}'::jsonb,
    -- ALLOWED:    counts, status, severity, lifecycle keys, refs, addresses
    -- FORBIDDEN:  body content, vector, secret, business text, personal data

  -- Timing
  occurred_at       timestamptz NOT NULL DEFAULT now(), -- when fact happened
  created_at        timestamptz NOT NULL DEFAULT now(), -- when event row inserted
  processed_at      timestamptz,                        -- (Phase 2 — for delivery sweeper)

  -- Constraints
  CHECK (event_domain IN ('iu','birth_registry','governance','tac','kg','system','dot','health')),
  CHECK (event_stream IN ('comment','review','update','birth','task','alert','health')),
  CHECK (event_severity IS NULL OR event_severity IN ('info','warning','critical')),
  -- per-domain (event_type, stream) coupling enforced by domain config (§F)
  CHECK (
    safe_payload IS NOT NULL
    AND NOT (safe_payload ? 'body')
    AND NOT (safe_payload ? 'content')
    AND NOT (safe_payload ? 'vector')
    AND NOT (safe_payload ? 'secret')
    AND NOT (safe_payload ? 'token')
  )
);

-- Idempotency (universal): a given subject + event_type fires at most once.
CREATE UNIQUE INDEX uq_event_outbox_subject_type
  ON event_outbox (event_domain, event_type, event_subject_table, event_subject_ref)
  WHERE event_subject_ref IS NOT NULL;

-- Hot indexes
CREATE INDEX idx_event_outbox_domain_stream_created
  ON event_outbox (event_domain, event_stream, created_at DESC);
CREATE INDEX idx_event_outbox_correlation
  ON event_outbox (correlation_id) WHERE correlation_id IS NOT NULL;
CREATE INDEX idx_event_outbox_severity_created
  ON event_outbox (event_severity, created_at DESC) WHERE event_severity IN ('warning','critical');

Cấm trong payload: body, raw content, vector, secret, token, password, personal data. CHECK constraint nâng (whitelist key only). Compliance với hard boundary "no body/raw payload/vector/secret exposure".

Trace fields lý do (rev2 contribution):

  • correlation_id — gom event của cùng job/import/build/run. Worker rollup dùng key này.
  • causation_id — chuỗi nhân quả (event A gây event B). Dùng cho audit trail (ví dụ: dot_apr_required → dot_executed → dot_failed).

§D. Recipient / Subscriber / Routing Model

Nguyên lý: Actor (producer) ≠ Recipient (consumer). Tách bạch để hỗ trợ broadcast, role routing, mute, escalation mà không phá hot path.

D.1 Recipient typology

Recipient kind Format actor_ref Ví dụ
Human user user:{login} user:huyen
Named AI agent {slug} hoặc agent:{slug} gpt, opus, agent:codex
Role-bound AI {role}:{slug} reviewer:gpt
Governance agency agency:{code} agency:apr
Role/group role:{name} role:reviewer, role:sysop
System service svc:{name} svc:hermes (future)

D.2 Subscription table (config-driven, Phase 2)

CREATE TABLE event_subscription (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  recipient_ref   text NOT NULL,             -- actor_ref hoặc role:NAME
  event_domain    text,                      -- NULL = any domain
  event_type      text,                      -- NULL = any type in domain
  event_stream    text,                      -- NULL = any stream
  scope_subject_table text,                  -- NULL = any subject; else restrict
  scope_filter    jsonb DEFAULT '{}'::jsonb, -- predicate (e.g. {"agency":"x"})
  mute            boolean NOT NULL DEFAULT false,
  created_at      timestamptz NOT NULL DEFAULT now(),
  CHECK (btrim(recipient_ref) != '')
);
CREATE INDEX idx_event_sub_recipient ON event_subscription(recipient_ref);
CREATE INDEX idx_event_sub_domain_type ON event_subscription(event_domain, event_type);

Resolution rule (PG view):

CREATE VIEW v_event_recipient AS
SELECT
  e.event_id,
  s.recipient_ref,
  s.mute
FROM event_outbox e
JOIN event_subscription s
  ON  (s.event_domain  IS NULL OR s.event_domain  = e.event_domain)
  AND (s.event_type    IS NULL OR s.event_type    = e.event_type)
  AND (s.event_stream  IS NULL OR s.event_stream  = e.event_stream)
  AND (s.scope_subject_table IS NULL OR s.scope_subject_table = e.event_subject_table)
WHERE NOT s.mute;

D.3 Phase 1 default — broadcast minus self

Nếu event_subscription chưa có row khớp → fall back broadcast (mọi known actor trừ creator). Đây là behavior hiện tại của P3D1+P3D2; giữ nguyên để không phá runtime.

D.4 Implicit self-read (universal rule)

Quy tắc đồng bộ với P3D2: actor tạo event mặc nhiên đã đọc (read_status_source='implicit_self'). Không tạo row trong event_read; fn_unread filter qua WHERE event.actor_ref != p_actor OR p_include_self.

Ưu điểm: zero-write trên hot path, idempotent, không phụ thuộc subscription resolution.

D.5 Escalation seam (Phase 3+)

Design seam (không implement): nếu event severity='critical' và không reader nào ack trong N phút → emit follow-up event *_unacked với recipient mở rộng (agency, sysop). Cron job + view, không cần infra mới. Tham chiếu §F về grouping.

D.6 Câu trả lời 4 câu hỏi rev2-Q2

Câu hỏi Phương án
Per-domain subscription? Per-event-type? Per-entity? Cả ba, nhờ NULL-wildcard trên 4 cột (domain/type/stream/subject_table) + scope_filter jsonb cho per-entity predicate.
Creator implicit self-read: universal rule? — quy tắc cứng tại fn_unread, không insert row read.
Agency/role routing: PG function or config table? Config table (event_subscription) + view resolution. Function chỉ dùng cho mark_read và unread query. Lý do: dễ thêm/bớt agency mà không phải redeploy function.
Escalation target: design seam? Yes (D.5). Cron + view, không hot path.

§E. Notification Lifecycle States (Phase 1 vs Later)

State Mô tả Phase 1 Implementation
pending Trong staging table, chưa rollup ✅ (P3D4C1 paused) event_pending.processed_at IS NULL
emitted Worker đã tạo durable event ✅ ACTIVE row tồn tại trong event_outbox
read Recipient explicit mark ✅ ACTIVE event_read.event_id exists for actor
implicit_self Creator auto-read ✅ ACTIVE computed (no row)
unread Chưa đọc, không phải creator ✅ ACTIVE computed (no row)
suppressed Mute/filter qua subscription Phase 2 event_subscription.mute=true hoặc no match
acknowledged Recipient confirm action taken Phase 2 new col event_read.ack_at; chỉ cho stream='task','alert'
resolved Subject entity resolved (issue closed, draft applied) Phase 1 (filter only) fn_unread LEFT JOIN subject; ẩn nếu lifecycle ≠ open. Không insert row read.
failed/retry Worker error Phase 1 (P3D4C1 paused) event_pending.processed_at IS NULL + event_worker_log.error_text
dead_letter Permanent fail Phase 2 new col event_pending.error_count >= N (threshold), separate triage view

Phase 1 = đã ACTIVE hoặc thuộc P3D4C1 paused. Mọi thứ khác là design seam, không implement.


§F. Generalized Grouping / Debounce

Nguyên lý: Grouping key là stable identifier of the originating job, không phải timing. Timing chỉ là debounce window cho phép rollup chạy trễ.

F.1 Grouping key catalog

Grouping context Stable key column(s) Ví dụ
IU import từ document source_document_ref "law/dieu44/v3.3"
IU import bulk import_batch_ref "import-2026-05-08-001"
DOT execution correlation_id = dot_run_id "dot-context-pack-build:20260508-015552-abcd"
Đ43 context-pack build correlation_id = build_id same as Đ43 §6
Workflow checkpoint batch correlation_id = workflow_run_id "wf-{uuid}"
Birth registry batch correlation_id = birth_batch_id "birth-{date}"
Health check sweep correlation_id = sweep_id "hc-sweep-{ts}"
Agency checkpoint correlation_id = checkpoint_id "ckpt-{id}"

Implementation: worker dùng COALESCE(source_document_ref, import_batch_ref, correlation_id) AS stable_key rồi GROUP BY stable_key HAVING count(*) >= threshold → emit rollup event; còn lại → emit piece-level. (Khớp P3D4C1 prompt rev3 §4 đã thiết kế cho IU; tổng quát hoá sang mọi domain chỉ là thêm 1 column correlation_id vào staging.)

F.2 Debounce universal vs per-domain

Khuyến nghị: universal debounce default 90s (clamped 60-300s qua dot_config), per-domain override qua event_domain_config (Phase 2 — chưa cần Phase 1).

Domain Debounce mặc định Lý do
iu 90s bursty import, user-acceptable latency
birth_registry 90s giống iu
dot 30s (override) cron run quan trọng, response sớm
health 30s (override) alert nhanh
system (Đ43) 0s (immediate) build lifecycle
tac, kg, governance 90s normal

F.3 Universal vs per-domain worker

Khuyến nghị Phase 1: 1 worker function fn_event_worker_tick() đọc tất cả event_pending, group theo (event_domain, stable_key). Ưu: 1 cron, 1 advisory lock, 1 log table. Nhược: nếu một domain lỗi, lock có thể block domain khác trong cùng tick (nhưng exception-safe đã giải quyết — release lock trên error).

Cân nhắc Phase 2: nếu volume một domain >> domain khác → tách worker (one cron per domain). Quyết định data-driven, không premature.


§G. PG Signal Channels Map

Channel Cơ chế PG Phase 1 Use case
Directus collection (read projection) PG view → Directus auto-discover ✅ (P3D3 design ready) Human board, AI sees same SoT
Agent SQL inbox fn_event_unread(), fn_event_mark_read(), fn_event_board() ✅ ACTIVE (P3D2) Direct PG access cho AI
Nuxt display Read từ Directus REST API ✅ (Assembly First) Human UI
DOT/ops health view PG view (e.g. v_event_recent_alerts) ✅ Phase 1 sysop monitoring
LISTEN/NOTIFY pg_notify('event_universal', json) từ trigger sau insert vào outbox Evaluate Phase 2 Real-time Agent push (nếu agent connect persistent)
Webhook adapter Cron sweeper đọc event_delivery chưa gửi → curl Phase 3+ design seam Slack/email/external
Email/chat Same as webhook Phase 3+ design seam

Ranh giới: Phase 1 và 2 không cần external scheduler. Chỉ pg_cron + Directus + Nuxt + Agent SQL. Webhook/email là adapter (PG-driven cron, không phải external service mới).

LISTEN/NOTIFY trade-off: mạnh khi agent persistent connection nhưng agent hiện tại chạy on-demand (Codex, Opus session) — polling qua fn_unread đã đủ. Defer Phase 2.


§H. Universal vs Per-Domain Tables — Strategy

Câu hỏi Khuyến nghị Phase 1 Khuyến nghị Phase 2 Justification
1 universal pending vs N per-domain N per-domain (giữ iu_notification_pending); Phase 2 bổ sung pending nếu domain mới cần debounce N per-domain Pending là staging, không phải SoT lâu dài. Per-domain index/CHECK linh hoạt hơn. Volume mỗi pending nhỏ.
1 universal event vs N per-domain Universal event_outbox cho domain Phase 2+; IU giữ iu_notification_event qua compat view Universal hoá hoàn toàn (PHASED_CONVERGENCE) Đa-domain query (board cross-domain) phải JOIN N table nếu per-domain — không hiệu quả; CHECK/index unified dễ duy trì.
1 universal read state vs N Universal event_read (event_id FK polymorphic không cần — dùng outbox.id) Universal Read state nhỏ, query per-actor luôn đi qua event_id; one table = one index per actor.
1 worker vs N 1 worker với group by domain 1 worker until volume forces split Operational simplicity.

Performance note: universal event_outbox với index (event_domain, event_stream, created_at DESC) xử lý tốt 1M+ rows. Partition theo created_at (monthly) là Phase 4 nếu volume vượt threshold.

Permission isolation: dùng RLS hoặc view per-domain để cấp quyền (ví dụ Directus role X chỉ thấy domain Y). Không cần tách table.


§I. IU Migration / Compatibility Recommendation

I.1 Hiện trạng (P3D1+P3D2 ACTIVE, deployed)

  • iu_notification_event (10 cols, 7 CHECK constraints, 6 indexes, FK→information_unit)
  • iu_notification_read (4 cols, unique on (event_id, actor_ref), CASCADE)
  • 6 functions: fn_iu_unread, fn_iu_mark_read, fn_iu_notification_board, fn_iu_notif_comment, fn_iu_notif_draft, fn_iu_notif_version
  • 3 triggers: trg_aa_iu_notif_comment/draft/version
  • Pilot rows retained (P3D2 test fixture)
  • Hash boundary: Pack 23 functions unchanged; P3D1+P3D2 hashes locked

I.2 4 options đánh giá

Option Approach Risk Effort Hot-path impact Compat Verdict
A — UNIVERSAL_FIRST Rename iu_notification_event → event_outbox, migrate data, drop iu_* fn, rewrite to fn_event_* High — agent context-pack nói fn_iu_unread, fn_iu_mark_read, fn_iu_notification_board là front door. Đổi tên sẽ phá Đ43 context (which is currently advertising these names) và mọi agent đang dùng. Directus collection re-register. High None (tables eventually rename) ❌ Breaking REJECT
B — Parallel + migrate Create event_outbox, dual-write (trigger fires both iu + outbox), gradually migrate readers, then drop iu Medium — dual-write doubles trigger work; data consistency window Medium-High 2× INSERT per fact ⚠️ Temporary Acceptable but wasteful
C — UNIVERSAL_WITH_IU_COMPAT Build event_outbox for new domains. Keep iu_notification_event as-is. Provide compatibility one of: (i) IU stays separate (zero risk) or (ii) iu_notification_event becomes a VIEW over event_outbox WHERE event_domain='iu' after one-time data copy and fn_iu_* redirected. Low (ii) — Medium (i) Low-Medium None fn_iu_* API invariant RECOMMENDED
D — IU_SPECIFIC_JUSTIFIED Keep IU notification standalone forever; new domains build separate notif tables Low Low None N/A Diverges → tech debt

I.3 Khuyến nghị: Option C — UNIVERSAL_WITH_IU_COMPAT (variant C-i first, C-ii later)

C-i (Phase 2 entry):

  • event_outbox is created alongside iu_notification_event. New domains (Đ43 system, DOT, Health, Birth, TAC, KG, Governance) write only to event_outbox.
  • IU stays on iu_notification_event — zero migration risk to active runtime.
  • Cross-domain board query: a UNION view v_event_unified exposes iu_notification_event columns mapped to event_outbox shape:
    CREATE VIEW v_event_unified AS  SELECT 'iu' AS event_domain, event_type, event_stream,         'information_unit' AS event_subject_type,         unit_id AS event_subject_ref, 'information_unit' AS event_subject_table,         canonical_address, ref_id, actor_ref, source AS source_system,         NULL::text AS correlation_id, NULL::uuid AS causation_id,         NULL::text AS source_document_ref, NULL::text AS import_batch_ref,         'safe_metadata' AS payload_classification, payload AS safe_payload,         created_at AS occurred_at, created_at, NULL::timestamptz AS processed_at,         id AS event_id  FROM iu_notification_event  UNION ALL  SELECT event_domain, event_type, event_stream, event_subject_type,         event_subject_ref, event_subject_table, canonical_address,         NULL::uuid AS ref_id, actor_ref, source_system,         correlation_id, causation_id, source_document_ref, import_batch_ref,         payload_classification, safe_payload, occurred_at, created_at, processed_at,         event_id  FROM event_outbox;
    
  • Hot-path impact: 0. fn_iu_unread, fn_iu_mark_read, fn_iu_notification_board không đổi.

C-ii (Phase 3, only after stability proven):

  • One-time copy iu_notification_eventevent_outbox with event_domain='iu'.
  • Drop physical iu_notification_event; create view iu_notification_event AS SELECT … FROM event_outbox WHERE event_domain='iu'.
  • iu_notification_readevent_read similarly.
  • fn_iu_* rewritten to query event_outbox filtered by domain. API surface (function names + return shape) unchanged.
  • Risk mitigated: Đ43 context-pack chỉ đang quảng cáo function names, không advertise table names → fn_iu_* signature giữ nguyên thì context-pack không cần re-build.

Hard constraint cho cả C-i và C-ii: KHÔNG phá fn_iu_unread, fn_iu_mark_read, fn_iu_notification_board — đây là front-door API của Đ44 IU runtime đã advertised. Bất kỳ thay đổi nào phải ở implementation, không signature.


§J. Đ43 Deep Assessment

Câu hỏi sub-1 đến sub-5 từ rev2 prompt §3 Q8.

J.1 Sub-1: Context-pack build/verify/health là universal event?

Có, nhưng map theo lifecycle:

  • context_pack_build_started (event_domain='system', stream='update') — optional Phase 2
  • context_pack_built (PASS) — Phase 2 (đã liệt kê row 16 trong matrix)
  • context_pack_failed — Phase 2 alert (row 17)
  • context_pack_promoted (staging→live swap) — Phase 2

correlation_id = build_id → group multiple sub-events của cùng build.

J.2 Sub-2: dot_config dùng chung hay giữ DOT-only?

Khuyến nghị: dùng chung (universal namespace).

dot_config hiện đã chứa keys notification.debounce_seconds, notification.batch_piece_threshold (P3D4C1 prompt §5 đã thiết kế). Mở rộng:

  • event.debounce_seconds.default = 90
  • event.debounce_seconds.{domain} = override
  • event.worker_lock_name = 'event_worker'

dot_config namespace bằng prefix (event.*, notification.*, dot.*, health.*) — không tách table.

J.3 Sub-3: Tránh duplicate worker/log/health với Đ43

Nhận diện overlap:

  • Đ43 đã có context_pack_request table + worker dot-context-pack-build.sh + context_pack_verify + advisory lock.
  • Universal event hệ thống có event_pending + fn_event_worker_tick + advisory lock.

Khuyến nghị:

  • Không duplicate. Đ43 worker (filesystem-side) gửi event vào event_outbox (event_domain='system') sau khi PASS/FAIL — coi như event producer.
  • Universal fn_event_worker_tick không tự rebuild context-pack — chỉ rollup events từ Đ43.
  • Đ43 system_health_checks, system_issues tiếp tục là SoT cho health (Đ22 lifecycle) — universal event chỉ phát thông báo về thay đổi state (issue_opened/resolved → row 23-24).

J.4 Sub-4: DOT execution pair (DOT + verify) biểu diễn trong universal schema?

Pair model:

  • dot_executed (event_type, stream='update') với payload.{exit_code, duration_ms}
  • dot_verified (event_type, stream='update') với causation_id = dot_executed.event_id
  • dot_failed (event_type, stream='alert', severity='warning') nếu exit_code != 0
  • correlation_id = dot_run_id → group cả pair.

Cú pháp correlation_id + causation_id đủ để truy ngược pair mà không cần extra table.

J.5 Sub-5: Universal event feed Đ43 context-pack?

Có, qua red_zones + new section recent_alerts:

  • Đ43 §6 đã có 8 sections cố định (project_map, laws_index, dot_registry, entities_overview, db_map, red_zones, architecture_mmd, project_map_json).
  • Đề xuất không thêm section mới (giữ stable schema). Thay vào đó: universal event feed red_zones.md.tmpl qua pg_query source — section render top N events severity in ('warning','critical') trong 24h gần nhất. Đ43 đã hỗ trợ source='pg_query' (template).

→ Universal event hệ thống không phá Đ43 contract; bổ sung vào red_zones là additive.


§K. Law Ownership Proposal

Component Candidate owner Justification
Event schema contract (envelope, CHECK, payload classification) HP (NT13 PG-first) + Đ44 (event là một loại "object" trong universe) Schema chung cho mọi domain → thuộc HP nguyên tắc. Nội dung specific (IU events) thuộc Đ44 §X mới.
Notification routing (subscription, recipient) Đ37 (Governance Org) vì agency/role là khái niệm Đ37; HOẶC luật mới Đ45 "Universal Event/Signal" Routing dựa trên agency/role là Đ37; ban hành tách law mới sạch hơn.
Directus exposure (collection mapping) Đ35 (DOT Governance) + Đ43 (System Context) Directus là exposure infrastructure, được Đ35/Đ43 quản.
Display rule (UI semantics) Đ28 (Display) Existing law.
Worker / pg_cron / advisory lock HP NT13 + Đ33 (PG/schema) PG-native infra.
Inclusion criteria (governance-significant) HP nguyên tắc mới hoặc Đ44 § Critical boundary; cần luật.

Khuyến nghị: KHÔNG ban hành luật mới ngay. P3D4C0X ghi note "candidate Đ45 Universal Event/Signal Law" rồi quyết sau Phase 2 PoC. Phase 1+2 dùng Đ44 extension (vì IU notification đã thuộc Đ44) cộng với cross-references vào Đ37/Đ35/Đ43.


§L. Directus / Nuxt Exposure Strategy

Đồng bộ với P3D3 Directus exposure design.

Element Phase 1 Phase 2
PG view per domain v_iu_notification_board (existing) v_event_universal_board, v_event_alerts_recent, v_dot_runs_recent, v_health_open_issues
Directus collection per-view (auto-discover) same
Per-actor filter Custom endpoint passing actor_ref (P3D3 §4 recommendation) same — universal endpoint /event/board?actor=...
Permission per role Directus role gates same + RLS optional
Nuxt Reuse existing Directus → assembly screens. Không code mới. TreeView 3-cột (domain × stream × event) optional; dựa Assembly First

Generic board + domain filter: Khuyến nghị một board v_event_universal_board(actor) + UI filter bar (domain in ('iu','dot',…), severity in ('warning','critical'), stream). Thay vì N board cứng. Reduce UI surface.

Assembly First compliance: không tạo Nuxt page mới; mọi exposure qua Directus collection được render bởi existing Nuxt assembly machinery (Đ7 Assembly First).


§M. Inclusion Criteria — "Governance-Significant" Definition

Bộ tiêu chí cứng để quyết một fact có được phép vào universal event hay không. Không thoả ≥ 1 → không phát event.

M.1 Inclusion (positive) — phải thoả ≥ 1

  1. Lifecycle change của entity được Đ0-G/Đ44 governance. Ví dụ: tạo, draft, approve, version_apply, archive, retire.
  2. Approval/review request mà human/AI cần action. Ví dụ: draft_created, approval_requested, dot_apr_required.
  3. Health/integrity alert có severity warning/critical. Ví dụ: health_check_alert, red_zone_violation, kg_constraint_violation.
  4. DOT execution result (Đ35 NT12 paired DOT). Ví dụ: dot_executed, dot_failed.
  5. System milestone (Đ43 lifecycle). Ví dụ: context_pack_built, context_pack_failed.
  6. Agency/governance task assigned. Ví dụ: checkpoint_due, approval_decided.
  7. Ingest milestone affecting governed entities. Ví dụ: document_imported, indexing_complete.

M.2 Exclusion (negative) — không bao giờ là event

  1. ❌ Mỗi UPDATE row một column.
  2. ❌ Telemetry/metrics (CPU, latency, throughput).
  3. ❌ Audit trail bình thường (đã có audit log riêng nếu cần — tách hệ thống).
  4. ❌ Debug/trace logs.
  5. ❌ Read access (no "read" event).
  6. ❌ Internal worker tick result (worker log riêng đã có).
  7. ❌ Any change without governance owner / lifecycle.

M.3 Decision rubric (cứng)

Producer hỏi 3 câu:
1. Entity có lifecycle ≥ 2 state, governance owner, identity ổn định? (Đ44 OQC tiêu chí Object)
2. Fact này representing transition of state hoặc requesting human/AI action?
3. Severity ≥ 'info' và actionable hoặc visible cho recipient?

Nếu 3 câu = YES → event qualified.
Nếu thiếu 1 → KHÔNG, dùng audit log/metrics riêng.

M.4 Enforcement seam

CHECK constraint trên event_outbox.event_domain + event_type — chỉ allow combos đã đăng ký vào event_type_registry (Phase 2 config table). Producer tries unknown combo → INSERT fail → trigger drops event silently (ON CONFLICT DO NOTHING semantics nếu dùng), hoặc raise notice (sysop catches).


§N. PG-First Exploitation Checklist

Layer PG Feature Phase 1 evidence
Fact capture AFTER INSERT trigger, WHEN predicate ✅ P3D2 trg_aa_iu_notif_*
Pending staging Table + partial index WHERE processed_at IS NULL ⏳ P3D4C1 paused
Worker pg_cron, pg_try_advisory_lock, plpgsql function with EXCEPTION block ⏳ P3D4C1 paused (cron rev3)
Durable event Table + CHECK + partial UNIQUE index for idempotency ✅ uq_notif_event_type_ref
Read state Table + UNIQUE constraint (event_id, actor_ref) ✅ uq_notif_read_event_actor
Routing View JOIN subscription (Phase 2) — not implemented
Exposure View → Directus auto-discover ✅ design ready (P3D3)
Signal LISTEN/NOTIFY (Phase 2 evaluate)
Config dot_config namespace partial (notif keys)
Lifecycle filter LEFT JOIN unit_edit_draft in fn_unread ✅ actionable filter
Provenance columns correlation_id, causation_id, source_*_ref (Phase 2) — partial in iu schema

No external scheduler/tool/service required for Phase 1 + Phase 2.


§O. Recommendation — Pick 1

Option Mô tả Verdict
UNIVERSAL_FIRST Build universal trước, migrate IU ngay. ❌ Risk cao, phá Đ44 front-door API
UNIVERSAL_WITH_IU_COMPAT Build universal cho domain mới; IU compat (variant C-i) → eventual convergence (variant C-ii). CHỌN
IU_SPECIFIC_JUSTIFIED Giữ IU riêng forever. ❌ Tạo tech debt + duplicate infra
PHASED_CONVERGENCE IU dần converge — tương đương C-ii nhưng trải dài Acceptable nhưng C bao gồm rồi

Final recommendation: UNIVERSAL_WITH_IU_COMPAT (tức Option C, C-i Phase 2 → C-ii Phase 3).

Lý do chọn

  1. Zero hot-path impact trên runtime IU đã ACTIVE.
  2. Zero breaking change với fn_iu_* API mà Đ43 context-pack đang advertise.
  3. Cross-domain board khả thi qua v_event_unified view — không cần wait migration.
  4. Migration path C-ii rõ ràng cho Phase 3 sau khi Phase 2 chứng minh stable.
  5. Operational cost thấp — 1 worker, 1 cron, 1 lock cho mọi domain mới. Reuse pg_cron + advisory_lock pattern đã chạy.
  6. No external dependency — pg_cron + plpgsql + view + Directus là đủ.

Next pack proposed (chỉ proposal, không dispatch)

P3D4C0Y — Phase 2 PoC scope plan:

  • §1 IU compat plan (C-i variant): tạo event_outbox, event_read, event_subscription schemas; dual-write trigger experimental cho 1 domain mới (gợi ý: system_issues từ Đ22) — chỉ DDL design, chưa apply.
  • §2 Migration windows + rollback contract.
  • §3 Test suite: parity với P3D1+P3D2; cross-domain board test; subscription routing test.

P3D4C0Y phải lại qua GPT/User review trước khi dispatch. P3D4C1 (IU staging+worker) vẫn pause và được đánh giá lại sau P3D4C0Y vì có thể bị thay thế hoặc generalized.


§P. Verification Block

phase_status=PASS
laws_surveyed=13/13
domain_event_matrix=PASS
five_layer_design=PASS
event_envelope_proposed=PASS
recipient_model_proposed=PASS
lifecycle_states_evaluated=PASS
lifecycle_phase1_defined=PASS
grouping_generalized=PASS
signal_channels_mapped=PASS
table_strategy_proposed=PASS
iu_migration_strategy=C
dieu43_deep_assessed=PASS
law_ownership_proposed=PASS
directus_strategy=PASS
inclusion_criteria_defined=PASS
pg_first_checklist=PASS
not_activity_log_boundary=PASS
recommendation=UNIVERSAL_WITH_IU_COMPAT
no_pg_mutation=true
no_directus_mutation=true
no_nuxt_code=true
no_hermes=true
no_codex_dispatch=true
no_external_scheduler=true
no_p3d4c1_resume=true
no_new_runtime_tables=true
no_iu_notification_runtime_change=true
no_body_payload_exposure=true
next_required_pack=P3D4C0Y_UNIVERSAL_PHASE2_POC_SCOPE_PLAN_REVIEW

P3D4C0X rev1 design note | Universal PG-to-outside signal architecture | 5 layers + envelope + recipient + lifecycle + grouping + signal + tables + IU compat + Đ43 + ownership + exposure + inclusion + PG-first | Recommendation: UNIVERSAL_WITH_IU_COMPAT | KB-only, no mutation

Back to Knowledge Hub knowledge/dev/laws/dieu44-trien-khai/design/23-p3d4c0x-universal-event-outbox-notification-architecture.md