IU Core 2400x — Directus table promotion (Slice A, migration 022)
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) |