KB-1C6C

IU Core 2400x — Directus table promotion (Slice A, migration 022)

8 min read Revision 1
iu-core2400xdieu44directusmigration-022table-promotioniu_three_axis_envelope

02 — Directus table promotion (Slice A, migration 022)

1. Why a table — exact 2000x blocker

The 1500x macro registered v_ui_iu_three_axis_envelope (a view) as a Directus collection with schema=null. The 2000x macro proved that Directus v11.5 refuses POST /fields/<collection> with HTTP 403 on that registration, even when the caller holds the Administrator policy with admin_access=true:

POST /fields/v_ui_iu_three_axis_envelope  → HTTP 403 (×16 fields)
GET  /items/v_ui_iu_three_axis_envelope   → HTTP 403

The block is a Directus convention: a view-backed collection has no underlying writable table, so the field manager — which normally CREATEs columns in the underlying table — refuses to run. The 2000x macro documented both paths (UI override or table promotion); 2400x picks Path A — table promotion because it keeps the Directus schema under version control via migration 022.

2. Migration 022 — additive only

sql/iu-core/022_three_axis_envelope_table_promotion.sql adds:

object role
iu_three_axis_envelope (table) Mirrors the view's 16 columns + refreshed_at / refreshed_by provenance. PRIMARY KEY (unit_id). 5 supporting indexes: canonical_address, lifecycle, axis_a sort, axis_c parent, axis_b GIN.
fn_iu_three_axis_envelope_refresh(actor, dry_run) Idempotent UPSERT-from-view + DELETE-no-longer-in-view in ONE transaction. Returns (upserted_count, deleted_count, view_count, table_count). dry_run=true returns the same counts without writing.
fn_iu_three_axis_envelope_drift_check() Read-only JSONB envelope: view_count, table_count, missing_in_table, missing_in_view, content_diff_count, in_sync, checked_at.
v_iu_three_axis_envelope_drift Per-unit_id FULL OUTER JOIN view; surfaces drift_kind in (missing_in_view, missing_in_table, content_diff, in_sync) + table provenance.

The view (v_ui_iu_three_axis_envelope from migration 020) is not modified. It remains the SSOT for the three-axis projection logic; the table is a refreshable derived cache. There is no second source of IU truth — every IU mutation still flows through information_unit + iu_metadata_tag + iu_tree_path (the inputs to the view).

3. Sandbox 210 — 8-probe BEGIN/ROLLBACK proof

Concatenated migration 022 + sandbox 210 + piped to the live postgres container. Result (probe by probe):

P1.table              exists=t
P1.indexes            index_count=6 (5 explicit + 1 PK)
P1.has_pk             t
P2.refresh            upserted=163 deleted=0 view=163 table=163
P2.counts_match       t
P3.idempotent         upserted=163 deleted=0  (UPSERT touches every row, no churn)
P4.drift              in_sync=t missing_in_table=0 missing_in_view=0 content_diff=0
P5.dry_no_write       before=163 after=163 unchanged=t
P6 (NOTICE)           before_refresh content_diff_count=1 -> refresh -> after_refresh in_sync=true
P7.column_parity      table_covers_view=t
P8.empty_after_delete table=0 view=163  (proves DELETE is reversible via refresh)
ROLLBACK

All 8 probes PASS inside the sandbox transaction; no durable production change from the probe itself.

4. Production apply (after sandbox PASS)

Migration 022 was then applied to the live postgres container:

CREATE TABLE / CREATE INDEX / CREATE FUNCTION / CREATE VIEW ... COMMIT

Followed by runtime/320_three_axis_envelope_initial_refresh.sql:

-- dry-run first
fn_iu_three_axis_envelope_refresh(actor='runtime_320_initial_refresh_dryrun', dry_run=true)
   -> upserted=163 deleted=0 view=163 table=0
-- live refresh
fn_iu_three_axis_envelope_refresh(actor='runtime_320_initial_refresh', dry_run=false)
   -> upserted=163 deleted=0 view=163 table=163
-- fail-closed fidelity check
fn_iu_three_axis_envelope_drift_check()
   -> {"in_sync": true, "view_count": 163, "table_count": 163, ...}

5. DOT scan after migration 022 (PASS)

runtime/110_iu_core_dot_conformance_scan.sql re-run on the live DB:

D9_conformance|config|8|8|t
D9_conformance|event_type|15|15|t
D9_conformance|function|49|49|t
D9_conformance|route|16|16|t
D9_conformance|table|20|20|t
D9_conformance|trigger|3|3|t
D9_conformance|view|21|21|t

132 / 132 PASS. (table 19->20, view 20->21, function 47->49; trigger / config / event_type / route unchanged.)

6. Directus apply — blocker now cleared

Driver /tmp/2400x-directus-promote.py (runs inside incomex-agent-data, where DIRECTUS_ADMIN_TOKEN lives — never in the repo, never logged):

discovered_fields=16     (from v_ui_iu_directus_registration_envelope)
legacy_view_get          status=200
legacy_view_delete       status=204   <- retire the 2000x view-backed registration
collection_get           status=200   <- Directus auto-discovered iu_three_axis_envelope when migration 022 created it
fields:  created=0 exists=16 failed=0
fields_list             status=200 count=18   (16 IU + refreshed_at + refreshed_by)
items_get               status=200 count=2    <- REAL READS now work (was 403 in 2000x)
admin_policy_id         8a613123-...
permissions_post        status=200    <- READ permission applied

Sample read via REST (GET /items/iu_three_axis_envelope?limit=2&fields=...):

{"data": [
  {"unit_id": "003bec16-...", "canonical_address": "D38-DIEU35-S8-P1",
   "axis_a_doc_code": "DIEU-35",
   "axis_b_tags": {"unit_kind": ["kind:law_unit"],
                    "section_type": ["sectype:governance_process"],
                    "legal_document": ["doc:DIEU-35"]}},
  {"unit_id": "0090a17a-...", "canonical_address": "D38-DIEU35-S1", ...}]}

The 2000x Directus blocker is CLOSED. Directus now exposes all three axes (axis A doc_code/sort_order/section_code, axis B tags + by_source, axis C parent_id/depth/ancestors/ancestor_addresses) via the normal REST items endpoint, under Administrator-policy READ.

7. Reversibility

layer rollback
Directus permissions DELETE /permissions/<id> (1 row, ID returned by GET /permissions?...)
Directus collection DELETE /collections/iu_three_axis_envelope (Directus does NOT drop the PG table)
Table data DELETE FROM iu_three_axis_envelope; — next refresh repopulates from view
Migration 022 sql/iu-core/rollback/022_three_axis_envelope_table_promotion.rollback.sql drops view -> fns -> table in dependency-safe order
DOT registration revert the runtime/110 + test edits (single commit 1c652d2)

8. Five-layer impact

layer impact
PG +1 table (iu_three_axis_envelope, 163 rows after refresh) + 2 fns + 1 view; runtime/320 ran (163 upserts)
Directus retired the view-backed registration; added the table registration + READ permission; collection now serves real GET /items/...
Nuxt none — assembly contract is unchanged (it never hard-coded the collection name; the env override knob is IU_CORE_DIRECTUS_COLLECTION=iu_three_axis_envelope)
AgentData +7 KB reports under dieu44-trien-khai/v0.6-iu-core-2400x-... (this directory)
Qdrant none in this section (Slice B, doc 03)
Back to Knowledge Hub knowledge/dev/laws/dieu44-trien-khai/v0.6-iu-core-2400x-directus-promotion-full-qdrant-reindex-open-goal/02-directus-table-promotion.md