23-P3D4C2U Option D — Base Table + Field Allowlist Implementation Report
23-P3D4C2U Option D — Base Table + Field Allowlist Implementation Report
Date: 2026-05-08 Pack: P3D4C2U_D Prompt: knowledge/dev/laws/dieu44-trien-khai/prompts/23-p3d4c2u-option-d-base-table-field-allowlist-implementation-prompt.md (rev4) Status: DRAFT_PENDING_SMOKE — registry row left as draft; needs Nuxt
tableIdMapmapping for end-to-end UI verification (out of pack scope) Phase: PASS for all in-scope steps (Directus permission + field metadata + table_registry); S9 route DirectusTable rendering CANNOT_VERIFY without Nuxt code change.
Summary
Option D applied successfully for the Directus + table_registry layer:
- Read permission
id=1483created onevent_outboxfor Public Access policy with metadata field allowlist (14 fields). - Field metadata translations (vi-VN labels) patched on all 14 allowlist fields.
table_registryrowid=21(tbl_event_outbox) created in draft status.- All field-level smokes (S1–S7) pass under the intended (anonymous/Public) role.
- Unsafe fields (
safe_payload,correlation_id,causation_id) are rejected with HTTP 403 when explicitly requested. - Write attempt (S4) rejected 403.
- Route
/knowledge/registries/event_outboxreturns 200 (S9), but the page'stableIdMap(inweb/pages/knowledge/registries/[entityType]/index.vue) does not includeevent_outbox→ DirectusTable is not actually mounted for this entityType. Adding the mapping is a Nuxt code change, which is OUT OF SCOPE for this pack. Hence registry stays draft; manual/Nuxt-followup smoke required.
Step 0 — Inventory
0A. View rollback residual = 0
GET /collections/v_event_outbox_table → 403 (does not exist; admin-as-non-existent)
pg_views.viewname='v_event_outbox_table' → 0 rows
table_registry WHERE table_id='tbl_event_outbox' → 0 rows
residual_from_view_path=0 ✅
0B. event_outbox base table + PK + columns
relkind='r' (table) ✓
PK: event_outbox_pkey ✓
row_count=0
Live columns:
id:uuid, event_domain:text, event_type:text, event_stream:text,
delivery_lane:text, event_severity:text, event_subject_table:text,
event_subject_ref:text, canonical_address:text, actor_ref:text,
source_system:text, correlation_id:text, payload_classification:text,
safe_payload:jsonb, occurred_at:timestamptz, created_at:timestamptz
Note: causation_id does NOT exist in schema (already absent — automatically safe).
0C. Directus sees event_outbox
GET /collections/event_outbox → 200 with meta:null, schema.name=event_outbox. ✅
0D. Existing event_outbox permissions
GET /permissions?filter[collection][_eq]=event_outbox → [] (none). No prior leak risk. Classification: N/A — no existing permissions.
0E. DirectusTable fetch path PROOF
directus_table_file=web/components/shared/DirectusTable.vue
+ web/composables/useDirectusTable.ts
directus_table_uses_fields_selector=YES_FROM_REGISTRY
directus_table_field_source=table_registry.fields (jsonb FieldConfig[])
directus_table_auth=PUBLIC_ROLE_ON_SSR + USER_ROLE_ON_CLIENT_SESSION
(NOT ADMIN_TOKEN — confirmed in web/modules/directus/runtime/plugins/directus.ts)
Code path:
DirectusTable.vuereadstable_registryfiltered bytable_id, getsfields: FieldConfig[].useDirectusTable.ts:36-40:sdkFields = options.fields.map(f => f.key); if (!fieldKeys.includes('id')) fieldKeys.unshift('id');→ passed toreadItems(collection, { fields: sdkFields.value, ... }).useDirectus()usesnuxtApp.$directus.request()— server side: anon REST client; client side: session-authenticated REST client. No admin token is ever used at runtime.- Route
/knowledge/*is inPUBLIC_PREFIXES(web/middleware/session.global.ts) → no session created → fetch is anonymous → Public Access policy.
fetch_path_safe=PASS ✅
0F. PG read role
SELECT on system_issues granted to: directus, incomex, context_pack_readonly
pg_read_role=directus (Directus runtime DB user; same role serves event_outbox)
0G. Directus read role / policy
system_issues precedent has 5 read permissions, one per policy:
e81a70bc...AI Agent Policy (INTERNAL_ADMIN/AGENT)a513bc9d...Public Access (PUBLIC) ← intended for/knowledge/*4ea86fab...Editor Policyabf8a154...$t:public_label (PUBLIC variant)74d6c90f...Agent Policy
directus_read_role_id=a513bc9d-b5df-47c4-9b6e-31da6a3b71b0
directus_read_role_name=Public Access
0H/0I. table_registry write channel + schema
Write channel: DIRECTUS_API (POST /items/table_registry succeeded as admin).
Live schema:
id:int, table_id:varchar, name:varchar, collection:varchar,
fields:jsonb, default_sort:varchar, default_filter:jsonb,
page_url:varchar, enable_insert_marks:bool, enable_proposals:bool,
enable_search:bool, enable_pagination:bool, rows_per_page:int,
status:varchar, module:varchar, description:text, _dot_origin:text
fields_config_column=fields
0J. system_issues precedent (shape)
Used as template; confirmed fields is jsonb array of {key,label,sortable?,filterable?,filterOptions?}.
0K. Duplicate check
SELECT FROM table_registry WHERE table_id='tbl_event_outbox' OR collection='event_outbox' → 0 rows ✅
0L. IU + event core snapshot (pre)
iu_notification_event=0
iu_notification_read=0
event_outbox=0
event_type_registry_active=3
Step 1 — Directus Read Permission
Created (was not present):
POST /permissions
collection=event_outbox
action=read
policy=a513bc9d-b5df-47c4-9b6e-31da6a3b71b0 (Public Access)
fields=[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]
permissions={} validation={} presets=null
→ id=1483 ✅
Unsafe field check on permission body: none of safe_payload, correlation_id, causation_id, payload, body, raw_payload, vector, embedding, secret, token, password, ssn, personal_data → ✅
permission_status=CREATED
permission_created_by_pack=P3D4C2U_D
permission_updated_by_pack=NO
NO create/update/delete permissions added.
Step 2 — Field Labels (vi-VN)
PATCH /fields/event_outbox/<f> with full default meta + translations:[{language:'vi-VN',translation:<label>}]. Required initial-meta defaults: interface:null, readonly:false, hidden:false, required:false, searchable:false, sort:null, width:'full'.
14 / 14 OK, 0 FAIL
| Field | Label |
|---|---|
| id | ID |
| occurred_at | Thời điểm |
| created_at | Tạo lúc |
| event_domain | Phạm vi |
| event_type | Loại sự kiện |
| event_stream | Luồng |
| delivery_lane | Kênh |
| event_severity | Mức độ |
| event_subject_table | Bảng nguồn |
| event_subject_ref | Đối tượng |
| canonical_address | Địa chỉ |
| actor_ref | Tác nhân |
| source_system | Nguồn |
| payload_classification | Phân loại payload |
field_labels_set=PASS
field_metadata_changed_by_pack=P3D4C2U_D
Step 3 — table_registry Row
POST /items/table_registry
table_id=tbl_event_outbox
name=Hộp thư sự kiện
collection=event_outbox
status=draft
default_sort=-occurred_at
page_url=/knowledge/registries/event_outbox
enable_insert_marks=false enable_proposals=false
enable_search=true enable_pagination=true rows_per_page=50
description="Bảng thông báo sự kiện hệ thống — metadata only, read-only"
_dot_origin="P3D4C2U_D|2026-05-08"
fields=[ 13 entries — subset of permission allowlist excluding 'id'
since DirectusTable always auto-includes id ]
→ id=21 ✅
Registry fields (13) are a strict subset of permission allowlist (14, including id).
permission_registry_fieldset_match=NARROWER_PERMISSION (permission ⊇ registry; both exclude all unsafe fields)
registry_status=CREATED
registry_initial_status=draft
registry_row_created_by_pack=P3D4C2U_D
Step 4 — Smoke S1–S9 (intended role = anonymous/Public)
| # | Test | Result |
|---|---|---|
| S1 | GET /items/system_issues?limit=1 (anon) |
PASS http=200, data returned |
| S2 | GET /items/event_outbox?limit=5&fields=id,event_domain,event_type,occurred_at (anon) |
PASS http=200, data:[] (count=0) |
| S3a | GET /items/event_outbox?fields=safe_payload (anon) |
BLOCKED http=403 FORBIDDEN |
| S3b | GET /items/event_outbox?fields=correlation_id (anon) |
BLOCKED http=403 FORBIDDEN |
| S3c | GET /items/event_outbox?fields=causation_id (anon) |
BLOCKED http=403 FORBIDDEN (also field doesn't exist in schema) |
| S4 | POST /items/event_outbox (anon) |
PASS http=403 (write denied) |
| S5 | table_registry row | PASS id=21 status=draft |
| S6 | GET /items/event_outbox?limit=1 (anon, full) |
http=200 data:[] (count=0) — see denial method below |
| S7 | IU + event core counts | PASS match 0L (all 0; etr_active=3) |
| S8 | /knowledge/registries/system_issue route |
PASS http=200, 271KB rendered |
| S9 | /knowledge/registries/event_outbox route |
CANNOT_VERIFY — see below |
S6 unsafe-field denial method (empty data)
event_outbox_row_count=0 so empty array does not by itself prove field blocking. Combined with:
- S3a/S3b/S3c returning explicit 403 for unsafe field names.
- Permission #1483 metadata audited:
fields=[14 metadata fields], leaks=[].
unsafe_field_denial_method=PERMISSION_METADATA_VERIFIED_WITH_EMPTY_DATA + HTTP_FORBIDDEN_FOR_NAMED_UNSAFE_FIELDS
S9 detail
curl https://vps.incomexsaigoncorp.vn/knowledge/registries/event_outbox → http=200, 256KB. The Nuxt route renders, but inspection of web/pages/knowledge/registries/[entityType]/index.vue:39-58 shows tableIdMap does not contain an event_outbox key, so tableId='' and the MANAGED branch (line 319) does NOT mount <SharedDirectusTable :table-id="..."> for this entityType. Adding the mapping (e.g., event_outbox: 'tbl_event_outbox') is required for end-user-visible rendering of the registry — this is a Nuxt code change, which is explicitly out of pack scope ("No Nuxt code" hard boundary).
smoke_S9_event_outbox_route=CANNOT_VERIFY (route 200 but DirectusTable not mounted; needs Nuxt tableIdMap entry)
Step 5 — Publish Decision
S1–S7 ALL PASS, S8 PASS, S9 CANNOT_VERIFY → per prompt rule:
S1-S7 PASS + S8/S9 CANNOT_VERIFY → status=draft, DEFERRED_MANUAL_ROUTE_SMOKE
published=NO (registry remains draft)
publish_block_reason=ROUTE_CANNOT_VERIFY
Verification block
residual_from_view_path=0
event_outbox_is_base_table=PASS
event_outbox_has_pk=PASS
live_columns=id,event_domain,event_type,event_stream,delivery_lane,event_severity,event_subject_table,event_subject_ref,canonical_address,actor_ref,source_system,correlation_id,payload_classification,safe_payload,occurred_at,created_at
event_outbox_row_count=0
directus_sees_event_outbox=PASS
permission_audit_by_scope=NONE_PRE_EXISTING
directus_table_file=web/components/shared/DirectusTable.vue + web/composables/useDirectusTable.ts
directus_table_uses_fields_selector=YES_FROM_REGISTRY
directus_table_field_source=table_registry.fields
directus_table_auth=PUBLIC_ROLE (SSR) | USER_SESSION (client) — NOT ADMIN_TOKEN
fetch_path_safe=PASS
pg_read_role=directus
directus_read_role=a513bc9d-b5df-47c4-9b6e-31da6a3b71b0/Public Access
write_channel=DIRECTUS_API
fields_config_column=fields
permission_status=CREATED
permission_id=1483
permission_allowed_fields=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
permission_created_by_pack=P3D4C2U_D
permission_updated_by_pack=NO
table_registry_id=21
table_registry_fields=occurred_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,created_at
unsafe_fields_absent_from_permission=PASS
unsafe_fields_absent_from_registry=PASS
permission_registry_fieldset_match=NARROWER_PERMISSION
registry_status=CREATED
registry_row_created_by_pack=P3D4C2U_D
registry_initial_status=draft
field_labels_set=PASS (14/14)
smoke_S1=PASS
smoke_S2=PASS
smoke_S3a_safe_payload=BLOCKED
smoke_S3b_correlation_id=BLOCKED
smoke_S3c_causation_id=BLOCKED
smoke_S4_write_denied=PASS
smoke_S5_registry_row=PASS
smoke_S6_no_unsafe_keys=PASS (via permission metadata + 403 on named unsafe fields; data empty)
smoke_S7_counts_match=PASS
smoke_S8_system_issues_route=PASS
smoke_S9_event_outbox_route=CANNOT_VERIFY (route 200 but tableIdMap missing event_outbox — Nuxt change out of scope)
unsafe_field_denial_method=PERMISSION_METADATA_VERIFIED_WITH_EMPTY_DATA + HTTP_FORBIDDEN_FOR_NAMED_UNSAFE_FIELDS
published=NO
publish_block_reason=ROUTE_CANNOT_VERIFY
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 (with deferred route smoke)
recommendation=READONLY_EXPOSURE_DRAFT_PENDING_SMOKE
next_required_pack=P3D4C2U_MANUAL_ROUTE_SMOKE (or NUXT_TABLEIDMAP_EXTEND_PROMPT to add `event_outbox: 'tbl_event_outbox'` to tableIdMap and rerun S9)
Pack-created artifact ledger (for rollback if needed)
| Artifact | Identifier | Action to roll back |
|---|---|---|
| Directus permission | id=1483 | DELETE /permissions/1483 |
| table_registry row | id=21 (table_id=tbl_event_outbox) | DELETE /items/table_registry/21 |
| Field meta translations on event_outbox.{14 fields} | translations[language=vi-VN] | revert via PATCH or leave (non-destructive) |
NOT touched: event_outbox table, IU collections, event core data, pre-existing permissions, PG roles, Directus roles, Nuxt code.
Follow-up (out of this pack)
- Nuxt code change (separate pack): add
event_outbox: 'tbl_event_outbox'totableIdMapinweb/pages/knowledge/registries/[entityType]/index.vue:39so the page mounts SharedDirectusTable for this entityType. - After Nuxt mapping deployed, re-run S9 by manually browsing
/knowledge/registries/event_outboxand verifying:- DirectusTable renders the 13 metadata columns under their Vietnamese labels.
- No unsafe field appears.
- Empty state is shown (event_outbox count=0 currently).
- Once verified, flip table_registry row id=21 from
status='draft'tostatus='published'.
P3D4C2U Option D | Implementation report | DRAFT_PENDING_SMOKE | 2026-05-08