KB-71B9

23-P3D4C2U Option D — Base Table + Field Allowlist Implementation Report

15 min read Revision 1
p3d4c2ureportimplementationoption-dbase-tablefield-allowlistrev4draft-pending-smoke

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 tableIdMap mapping 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=1483 created on event_outbox for Public Access policy with metadata field allowlist (14 fields).
  • Field metadata translations (vi-VN labels) patched on all 14 allowlist fields.
  • table_registry row id=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_outbox returns 200 (S9), but the page's tableIdMap (in web/pages/knowledge/registries/[entityType]/index.vue) does not include event_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.vue reads table_registry filtered by table_id, gets fields: FieldConfig[].
  • useDirectusTable.ts:36-40: sdkFields = options.fields.map(f => f.key); if (!fieldKeys.includes('id')) fieldKeys.unshift('id'); → passed to readItems(collection, { fields: sdkFields.value, ... }).
  • useDirectus() uses nuxtApp.$directus.request() — server side: anon REST client; client side: session-authenticated REST client. No admin token is ever used at runtime.
  • Route /knowledge/* is in PUBLIC_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 Policy
  • abf8a154... $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)

  1. Nuxt code change (separate pack): add event_outbox: 'tbl_event_outbox' to tableIdMap in web/pages/knowledge/registries/[entityType]/index.vue:39 so the page mounts SharedDirectusTable for this entityType.
  2. After Nuxt mapping deployed, re-run S9 by manually browsing /knowledge/registries/event_outbox and verifying:
    • DirectusTable renders the 13 metadata columns under their Vietnamese labels.
    • No unsafe field appears.
    • Empty state is shown (event_outbox count=0 currently).
  3. Once verified, flip table_registry row id=21 from status='draft' to status='published'.

P3D4C2U Option D | Implementation report | DRAFT_PENDING_SMOKE | 2026-05-08

Back to Knowledge Hub knowledge/dev/laws/dieu44-trien-khai/reports/23-p3d4c2u-option-d-base-table-field-allowlist-implementation-report.md