IU Core 4000x — 02 Migration 024 auto-refresh trigger
02 — Migration 024 · auto-refresh trigger · wiring the 023 wrapper to the IU lifecycle
1. Why
Migration 023 (IU_CORE_3000X_…) shipped the drift-gated, audited refresh wrapper fn_iu_three_axis_envelope_refresh_if_stale + the iu_three_axis_envelope_refresh_log + the v_iu_three_axis_envelope_refresh_status + the reserved gate iu_core.three_axis_auto_refresh_enabled. It deliberately did not install a trigger; the operator had to call the wrapper by hand or schedule it externally.
Migration 024 closes that loop without changing any existing object:
- two statement-level AFTER triggers — one on
information_unit, one oniu_metadata_tag— share a single trigger function that fast-paths when the gate is false (the default) and callsfn_iu_three_axis_envelope_refresh_if_stale('iu_lifecycle_trigger', false, false)when the gate is true. - an
EXCEPTION WHEN OTHERSblock inside the trigger function captures any failure and writes one row to a new error log tableiu_three_axis_envelope_trigger_error_log. The caller's IU write can never be aborted by an envelope-side failure.
2. Surface added (3 objects = +1 table / +1 function / +2 triggers)
| Class | Name | Role |
|---|---|---|
| table | iu_three_axis_envelope_trigger_error_log |
Append-only audit of trigger-level exceptions. Successful fires are not logged here (refresh_log already records those); this table exists only so the EXCEPTION swallow remains observable. CHECK on tg_op IN ('INSERT','UPDATE','DELETE','TRUNCATE'). |
| function | fn_iu_three_axis_envelope_auto_refresh_trigger() |
Statement-level trigger payload. Reads the gate, fast-paths when false, else calls refresh_if_stale and catches OTHERS. Always RETURN NULL. |
| trigger | trg_iu_three_axis_envelope_auto_refresh_iu |
AFTER INSERT OR UPDATE OR DELETE ON information_unit FOR EACH STATEMENT. |
| trigger | trg_iu_three_axis_envelope_auto_refresh_tag |
AFTER INSERT OR UPDATE OR DELETE ON iu_metadata_tag FOR EACH STATEMENT. |
DOT bump: 140/140 PASS (table 21→22, view 22 (unchanged), function 50→51, trigger 3→5, config 9 (unchanged), event_type 15 (unchanged), route 16 (unchanged)).
3. Safety / no-failure-propagation guarantee
The trigger function reads the gate; if not 'true', returns NULL immediately (one dot_config row read, no envelope work). When the gate is true, calls fn_iu_three_axis_envelope_refresh_if_stale('iu_lifecycle_trigger', false, false) inside an inner BEGIN..EXCEPTION WHEN OTHERS block. Any error is captured to iu_three_axis_envelope_trigger_error_log (trigger_name, host_table, tg_op, sqlstate, sqlerrm) and swallowed. Returns NULL.
Key invariants:
- Gate-off cost ≈ free. One dot_config row read; no envelope work.
- Gate-on cost = 023 drift-gated refresh + one log row. Skip when
in_sync = true. - No write abort. Any exception inside the inner BEGIN/EXCEPTION is captured to
iu_three_axis_envelope_trigger_error_logand swallowed. - Statement-level. Bulk lifecycle operations (
UPDATEover N rows) incur exactly one refresh attempt per statement, not N. RETURN NULL. AFTER-statement triggers cannot modify row state; returning NULL is the canonical Postgres signal that no row was processed.
4. Sandbox/230 BEGIN/ROLLBACK probe (7 probes / live schema / no commit)
| # | Probe | Outcome |
|---|---|---|
| 230.1 | gate=false → UPDATE on information_unit fires the trigger but writes no refresh_log row | PASS (delta_log_rows = 0) |
| 230.2 | gate=true, clean cache → trigger calls refresh_if_stale → skipped_in_sync |
PASS |
| 230.3 | gate=true, seeded drift (one envelope row deleted) → trigger repairs (163 upserts, refreshed) |
PASS |
| 230.4 | gate=true, UPDATE on iu_metadata_tag → tag-side trigger fires the same path | PASS (rows_recent ≥ 1) |
| 230.5 | gate=true, refresh_if_stale temporarily replaced with a raising stub → caller's UPDATE NOT aborted, error captured in trigger_error_log (sqlstate P0001) |
PASS |
| 230.6 | gate flipped false again → UPDATE fires trigger but writes nothing → gate is the safe disable switch | PASS (delta_log_rows = 0) |
| 230.7 | DOT visibility — 2 triggers + 1 function + 1 error log present | PASS (trigger_n=2, fn_n=1, table_n=1) |
230.5's mechanism — temporary CREATE OR REPLACE FUNCTION of fn_iu_three_axis_envelope_refresh_if_stale with a RAISE EXCEPTION body inside the same BEGIN..ROLLBACK — is the canonical exception-swallow test pattern; the ROLLBACK restores the real function automatically.
5. Runtime/340 — durable smoke (one controlled gate cycle)
340.1 gate_precondition: iu_core.three_axis_auto_refresh_enabled = false → PASS
340.2 UPDATE dot_config set value='true'
340.3 no-op-ish UPDATE on one enacted information_unit (canonical_writer marker set)
340.4 trigger_fired: refresh_log id=14 actor=iu_lifecycle_trigger outcome=skipped_in_sync → PASS
340.5 UPDATE dot_config set value='false'
340.6 final_state: gate=false, cache_healthy=t, in_sync=t
340.7 fail-closed assertion (≥1 row written)
COMMIT
The only durable side effect is one row in iu_three_axis_envelope_refresh_log under actor='iu_lifecycle_trigger' — reversible by sql/iu-core/runtime/rollback/340_…rollback.sql (DELETE BY actor).
6. Rollback
sql/iu-core/rollback/024_three_axis_envelope_auto_refresh_trigger.rollback.sql drops in safe order: triggers → trigger function → error log table. Does NOT touch any 023 / 022 / 020 / earlier object — every prior rollback file still applies cleanly to the pre-024 state. The 024 rollback's only pre-condition is the gate being false, which is the default.
7. Five-layer impact
| layer | impact |
|---|---|
| PG | migration 024 (+1 table / +1 function / +2 triggers); runtime/340 (1 audit row id=14); gate stays false |
| Directus | none — 2400x state preserved |
| Nuxt | none — UI package authored in iu-cutter, NOT deployed (see doc 03) |
| AgentData | +7 KB reports (this directory) |
| Qdrant | none — read-only retrieval/payload audit; collection untouched |