23-P3D4C2U Option D — Base Table + Field Allowlist Implementation Prompt (rev4 CONSOLIDATED)
23-P3D4C2U Option D — Base Table + Field Allowlist Implementation Prompt (rev4 CONSOLIDATED)
Date: 2026-05-08 Status: PROMPT rev4 — chờ GPT/User final review. CHƯA dispatch. Report: knowledge/dev/laws/dieu44-trien-khai/reports/23-p3d4c2u-option-d-base-table-field-allowlist-implementation-report.md Scope: Expose event_outbox base table via Directus field allowlist + table_registry. NO view. NO Nuxt. Self-contained: prompt này chứa đầy đủ mọi bước, không tham chiếu rev trước.
Hard Boundaries
- ❌ KHÔNG PG view / materialized view
- ❌ KHÔNG worker / cron / refresh
- ❌ KHÔNG new table / write path
- ❌ KHÔNG custom Directus extension
- ❌ KHÔNG Nuxt code / bespoke notification UI
- ❌ KHÔNG Directus click-config (API only)
- ❌ KHÔNG Directus write / mark-read
- ❌ KHÔNG expose unsafe fields (safe_payload, correlation_id, causation_id, payload, body, raw_payload, vector, embedding, secret, token, password, ssn, personal_data)
- ❌ KHÔNG touch IU runtime / Đ43 machinery
- ❌ KHÔNG tạo PG role hoặc Directus role mới
- ✅ Directus permission + field metadata (API)
- ✅ table_registry row (convention channel)
- ✅ Report MUST be created even on FAIL/BLOCKED
Step 0: Verify Clean State + Full Inventory
0A. View rollback residual = 0
GET /collections/v_event_outbox_table → expect 404
SELECT viewname FROM pg_views WHERE viewname = 'v_event_outbox_table'; → expect 0 rows
SELECT * FROM table_registry WHERE table_id = 'tbl_event_outbox'; → expect 0 rows
0B. event_outbox is base table with real PK
SELECT relkind FROM pg_class WHERE relname = 'event_outbox'; → expect 'r' (table)
SELECT conname FROM pg_constraint WHERE conrelid = 'event_outbox'::regclass AND contype = 'p'; → PK exists
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'event_outbox' ORDER BY ordinal_position;
SELECT count(*) FROM event_outbox;
Report: live_columns=<list>, event_outbox_row_count=<N>.
0C. Directus sees event_outbox
GET /collections/event_outbox → expect collection info
0D. ALL permissions on event_outbox — CLASSIFIED by scope
GET /permissions?filter[collection][_eq]=event_outbox
Classify each:
| Scope | Description | Unsafe field leak = ? |
|---|---|---|
| TABLE_MODULE_USER_FACING | Role used by DirectusTable | STOP |
| PUBLIC | Public Access policy | STOP |
| INTERNAL_ADMIN | Administrator | Report only — UNLESS Table Module uses admin token (then STOP) |
| AGENT_INTERNAL | AI agent role | Report only |
| UNKNOWN | Cannot classify | STOP |
Report: permission_audit_by_scope=<classified list>.
0E. DirectusTable fetch path PROOF (CRITICAL GATE)
Agent must provide code-level evidence:
# Find DirectusTable component
grep -rn "DirectusTable\|directus-table" web/
# Check fields selector usage
grep -n "fields" <found file>
# Check auth method
grep -n "readItems\|useDirectus\|fetch\|token" <found file>
Report:
directus_table_file=<path>
directus_table_uses_fields_selector=YES_FROM_REGISTRY|YES_HARDCODED|NO|UNKNOWN
directus_table_field_source=<actual table_registry column name>
directus_table_auth=PUBLIC_ROLE|USER_ROLE|ADMIN_TOKEN|UNKNOWN
If fields_selector = NO or UNKNOWN → STOP NEEDS_TABLE_MODULE_FIELD_FILTER.
If auth = ADMIN_TOKEN AND admin permission leaks unsafe fields → STOP.
0F. PG role convention
SELECT grantee, privilege_type FROM information_schema.role_table_grants
WHERE table_name = 'system_issues' AND privilege_type = 'SELECT';
Report: pg_read_role=<name>.
0G. Directus role convention
GET /permissions?filter[collection][_eq]=system_issues&filter[action][_eq]=read
Report: directus_read_role_id=<uuid>, directus_read_role_name=<name>.
0H. table_registry write channel
SELECT id, collection, status FROM table_registry ORDER BY id;
Report: write_channel=DIRECTUS_API|RAW_SQL.
0I. table_registry LIVE schema (DO NOT guess columns)
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'table_registry' ORDER BY ordinal_position;
Report: fields_config_column=<actual name>.
0J. system_issues row (precedent shape)
SELECT * FROM table_registry WHERE collection = 'system_issues';
0K. Duplicate check
SELECT * FROM table_registry WHERE table_id = 'tbl_event_outbox' OR collection = 'event_outbox';
Exists compatible → skip. Conflict → STOP.
0L. IU + event core snapshot
SELECT count(*) FROM iu_notification_event;
SELECT count(*) FROM iu_notification_read;
SELECT count(*) FROM event_outbox;
SELECT count(*) FROM event_type_registry WHERE active = true;
Step 1: Directus Permission — Field Allowlist
Only include fields that EXIST in live schema (from 0B).
Approved metadata fields (verify each exists in live_columns):
id, occurred_at, created_at, event_domain, event_type, event_stream,
delivery_lane, event_severity, event_subject_table, event_subject_ref,
canonical_address, actor_ref, source_system, payload_classification
DENY list (must NOT appear in permission fields):
safe_payload, correlation_id, causation_id,
payload, body, raw_payload, vector, embedding,
secret, token, password, ssn, personal_data
Idempotent check:
GET /permissions?filter[collection][_eq]=event_outbox&filter[action][_eq]=read&filter[role][_eq]=<directus_read_role_id>
- Not found → create (POST /permissions)
- Found + compatible (same fields or narrower) → keep. If narrower: adjust registry fields to match permission, OR widen permission (only metadata fields) and report
permission_updated_by_pack=P3D4C2U_D - Found + broader (includes unsafe fields) → STOP
NO create/update/delete permissions. Track: permission_created_by_pack=P3D4C2U_D.
Report: permission_allowed_fields=<list>.
Step 2: Directus Field Labels (API, idempotent)
For each allowed field in event_outbox:
GET /fields/event_outbox/<field>— verify exists- If exists → PATCH label (Vietnamese). Preserve other settings.
- Do NOT touch fields outside event_outbox.
Labels: Thời điểm, Tạo lúc, Phạm vi, Loại sự kiện, Luồng, Kênh, Mức độ, Bảng nguồn, Đối tượng, Địa chỉ, Tác nhân, Nguồn, Phân loại payload, ID.
Track: field_metadata_changed_by_pack=P3D4C2U_D.
Step 3: table_registry Row
Idempotent: Check 0K first.
Use ACTUAL column names from 0I. Copy shape from 0J (system_issues precedent). Use write channel from 0H.
collection = 'event_outbox'
table_id = 'tbl_event_outbox'
status = 'draft'
description = 'Bảng thông báo sự kiện hệ thống — metadata only, read-only'
default_sort = '-occurred_at'
fields = <only fields matching permission allowlist>
Field list in registry MUST match or be subset of permission allowlist. Report: permission_registry_fieldset_match=PASS|NARROWER_PERMISSION|FAIL.
Track: registry_row_created_by_pack=P3D4C2U_D.
Step 4: Smoke Tests (INTENDED ROLE — not admin)
Admin token only for setup/inventory. All read smoke uses intended Directus role.
| # | Test | Expected |
|---|---|---|
| S1 | GET /items/system_issues?limit=1 (intended role) |
Data returned |
| S2 | GET /items/event_outbox?limit=5&fields=id,event_domain,event_type,occurred_at (intended role) |
Data or empty array (not 403) |
| S3a | GET /items/event_outbox?fields=safe_payload (intended role) |
403 / field omitted / empty — NOT value |
| S3b | GET /items/event_outbox?fields=correlation_id (intended role) |
Same |
| S3c | GET /items/event_outbox?fields=causation_id (intended role) |
Same |
| S4 | POST /items/event_outbox (intended role) |
403 |
| S5 | table_registry row | 1 row, status=draft |
| S6 | Full response GET /items/event_outbox?limit=1 → list all keys |
No unsafe key |
| S7 | IU + event core counts | Match 0L |
| S8 | Route /knowledge/registries/system_issue (if tool can verify) |
Renders |
| S9 | Route /knowledge/registries/event_outbox (if tool can verify, after draft row) |
Renders |
Empty table handling for S3a-S3c + S6:
If event_outbox_row_count = 0:
- Response
[]does NOT prove field blocking - MUST ALSO verify permission metadata:
GET /permissions?filter[collection][_eq]=event_outbox&filter[action][_eq]=read→ confirm fields list does NOT include safe_payload/correlation_id/causation_id - Report:
unsafe_field_denial_method=PERMISSION_METADATA_VERIFIED_WITH_EMPTY_DATA
If event_outbox_row_count > 0:
- Response with data: unsafe key present = FAIL, absent = PASS
- Report:
unsafe_field_denial_method=FIELD_OMITTED_IN_NONEMPTY_RESPONSE
Step 5: Publish Decision
- ALL S1-S9 PASS →
UPDATE status='published' - S1-S7 PASS + S8/S9 CANNOT_VERIFY →
status=draft,DEFERRED_MANUAL_ROUTE_SMOKE - Any S1-S7 FAIL → rollback
Report:
published=YES|NO
publish_block_reason=NONE|ROUTE_CANNOT_VERIFY|SMOKE_FAIL|PERMISSION_LEAK|FETCH_PATH_UNSAFE
Step 6: Rollback (scoped to P3D4C2U_D only)
Only remove objects created/updated by THIS pack:
permission_created_by_pack=P3D4C2U_D → DELETE /permissions/<id>
permission_updated_by_pack=P3D4C2U_D → revert to original fields (if tracked)
registry_row_created_by_pack=P3D4C2U_D → DELETE row
field_metadata_changed_by_pack=P3D4C2U_D → revert labels or leave (non-destructive)
DO NOT: drop event_outbox, touch IU, touch event core data, remove pre-existing permissions.
Verification
residual_from_view_path=0
event_outbox_is_base_table=PASS
event_outbox_has_pk=PASS
live_columns=<list>
event_outbox_row_count=<N>
directus_sees_event_outbox=PASS
permission_audit_by_scope=<classified list>
directus_table_file=<path>
directus_table_uses_fields_selector=YES_FROM_REGISTRY|NO|UNKNOWN
directus_table_field_source=<column name>
directus_table_auth=<role/token type>
fetch_path_safe=PASS|STOP
pg_read_role=<name>
directus_read_role=<uuid>/<name>
write_channel=DIRECTUS_API|RAW_SQL
fields_config_column=<actual>
permission_status=CREATED|ALREADY_COMPATIBLE|NARROWER|BROADER_STOP
permission_allowed_fields=<list>
permission_created_by_pack=P3D4C2U_D|SKIPPED
permission_updated_by_pack=P3D4C2U_D|NO
table_registry_fields=<list>
unsafe_fields_absent_from_permission=PASS|FAIL
unsafe_fields_absent_from_registry=PASS|FAIL
permission_registry_fieldset_match=PASS|NARROWER_PERMISSION|FAIL
registry_status=CREATED|ALREADY_COMPATIBLE|CONFLICT
registry_row_created_by_pack=P3D4C2U_D|SKIPPED
registry_initial_status=draft
field_labels_set=PASS|FAIL
smoke_S1=PASS|FAIL
smoke_S2=PASS|FAIL
smoke_S3a_safe_payload=BLOCKED|FAIL
smoke_S3b_correlation_id=BLOCKED|FAIL
smoke_S3c_causation_id=BLOCKED|FAIL
smoke_S4_write_denied=PASS|FAIL
smoke_S5_registry_row=PASS|FAIL
smoke_S6_no_unsafe_keys=PASS|FAIL
smoke_S7_counts_match=PASS|FAIL
smoke_S8_system_issues_route=PASS|CANNOT_VERIFY
smoke_S9_event_outbox_route=PASS|CANNOT_VERIFY
unsafe_field_denial_method=HTTP_FORBIDDEN|FIELD_OMITTED_IN_NONEMPTY|PERMISSION_METADATA_VERIFIED_WITH_EMPTY_DATA|FAIL
published=YES|NO
publish_block_reason=NONE|ROUTE_CANNOT_VERIFY|SMOKE_FAIL|PERMISSION_LEAK|FETCH_PATH_UNSAFE
iu_post_match=PASS
event_core_post_match=PASS
no_view_created=true
no_nuxt_code=true
no_bespoke_ui=true
no_new_role=true
phase_status=PASS|FAIL|BLOCKED
recommendation=READONLY_EXPOSURE_PUBLISHED|READONLY_EXPOSURE_DRAFT_PENDING_SMOKE|NEEDS_TABLE_MODULE_FIELD_FILTER|EXISTING_PERMISSION_LEAK|BLOCKED
next_required_pack=(conditional — see below)
Next pack conditional:
- PUBLISHED →
P3D4C3U_USER_VIEW_SMOKE_AND_MARK_READ_DECISION - DRAFT pending route smoke →
P3D4C2U_MANUAL_ROUTE_SMOKE - Field filter missing →
TABLE_MODULE_FIELD_FILTER_EXTENSION_PROMPT_REVIEW - Permission leak →
FIELD_PERMISSION_REPAIR_PROMPT_REVIEW
P3D4C2U Option D rev4 CONSOLIDATED | Self-contained | Base table + field allowlist | CHƯA dispatch | Chờ GPT/User final review