KB-2FE7

11000x · 04 — Emit hooks design: trigger on iu_lifecycle_log (not fn rewrite)

4 min read Revision 1
iu-corev0.611000xemit-hookstriggerlifecycle-logsupersedesplitmerge

11000x · 04 — Emit hooks: trigger on iu_lifecycle_log

Two candidate designs

Design Lines touched Risk Reversibility
(A) CREATE OR REPLACE fn_iu_supersede / fn_iu_piece_split / fn_iu_piece_merge to call fn_iu_emit_event inline before RETURN ~3 × 100+ line function bodies rewritten high — subtle bugs in a 200-line plpgsql block; rollback requires restoring exact prior fn body medium
(B) AFTER INSERT TRIGGER on iu_lifecycle_log that fires fn_iu_piece_emit_event when transition_type IN (supersede, split, merge) ~40 lines of new fn + 4 lines of CREATE TRIGGER low — no existing fn touched; trigger drop reverses cleanly high

Decision: Design B, ratified by the macro Decision Bank's safety priorities.

Why design B wins

  1. fn_iu_supersede / fn_iu_piece_split / fn_iu_piece_merge stay byte-identical. Migration 026/027/028's behaviour is provably unchanged — and a pg_get_functiondef diff before/after 11000x's apply confirms it.
  2. iu_lifecycle_log is the canonical audit log. By definition every successful lifecycle transition writes one row there. The trigger thus has 100% coverage with no missed paths.
  3. Failure modes are local. If the trigger fn errors (e.g. registry lookup mismatch), the lifecycle INSERT also fails — but ONLY when emit_enabled is open. With the default gate closed, the trigger fn returns immediately (no work). So in production, where the gate stays closed, the trigger is a complete no-op.
  4. Easy operator gating: flipping piece_event_runtime.emit_enabled is a single UPDATE row. No code redeploy. The runbook (05-operator-runbook.md) documents the steps.

Transition_type → piece event_type mapping

CASE NEW.transition_type
    WHEN 'supersede' THEN 'superseded'
    WHEN 'split'     THEN 'split'
    WHEN 'merge'     THEN 'merged'
    ELSE NULL          -- including 'enact', 'retire', anything else
END

The enact transition (currently 146 rows in iu_lifecycle_log) is intentionally NOT mapped: enacted events are emitted via the iu-domain path (fn_iu_emit_event with 'iu.version_applied' or similar). Piece domain is reserved for the 3 piece-lifecycle transitions.

What the trigger fn writes to safe_payload

{
  "lifecycle_log_id": "<uuid>",
  "from_status": "<text>",
  "to_status": "<text>",
  "transition_type": "supersede|split|merge",
  "review_decision_id": "<uuid|null>",
  "change_set_id": "<uuid|null>",
  "tool_revision": "<text|null>",
  "emit_mode": "dry_run|live"
}

No body, content, raw, vector, embedding, secret, token, password, ssn, personal_data — all banned by the existing event_outbox_safe_payload_check constraint, which is the project-wide "no PII in safe_payload" policy enforcer.

Idempotency

The fn_iu_piece_emit_event INSERT has ON CONFLICT DO NOTHING. Combined with the lifecycle_log_id in the payload, this means re-driving the same lifecycle row by accident (e.g. replay tooling) cannot emit twice for the same log row.

Author-mode tests (no DB, no SSH)

tests/test_iu_core_piece_event_runtime.py includes:

  • test_gate_short_circuits_before_insert: confirms piece_event_runtime.emit_enabled is checked BEFORE INSERT INTO public.event_outbox in the fn body.
  • test_trigger_maps_only_piece_transitions: confirms the CASE statement mentions all 3 piece transitions.
  • test_post_apply_assertion_present: confirms migration 029 ends with a healthcheck assertion that would RAISE EXCEPTION on partial apply.

All static, all live-environment-independent.

Live proof (bounded)

See 06-bounded-live-proof-transcript.md for the BEGIN/ROLLBACK proof that exercised this trigger in 4 phases.

Back to Knowledge Hub knowledge/dev/laws/dieu44-trien-khai/v0.6-iu-core-11000x-piece-event-runtime-product-factory-open-goal/04-emit-hooks-design.md