KB-3037

IU Core 4000x — 02 Migration 024 auto-refresh trigger

7 min read Revision 1
iu-core4000xmigration-024auto-refresh-triggerexception-swallowsandbox-230runtime-340

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 on iu_metadata_tag — share a single trigger function that fast-paths when the gate is false (the default) and calls fn_iu_three_axis_envelope_refresh_if_stale('iu_lifecycle_trigger', false, false) when the gate is true.
  • an EXCEPTION WHEN OTHERS block inside the trigger function captures any failure and writes one row to a new error log table iu_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:

  1. Gate-off cost ≈ free. One dot_config row read; no envelope work.
  2. Gate-on cost = 023 drift-gated refresh + one log row. Skip when in_sync = true.
  3. No write abort. Any exception inside the inner BEGIN/EXCEPTION is captured to iu_three_axis_envelope_trigger_error_log and swallowed.
  4. Statement-level. Bulk lifecycle operations (UPDATE over N rows) incur exactly one refresh attempt per statement, not N.
  5. 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
Back to Knowledge Hub knowledge/dev/laws/dieu44-trien-khai/v0.6-iu-core-4000x-ui-runtime-acceptance-monitoring-rollout-open-goal/02-migration-024-auto-refresh-trigger.md