11000x · 04 — Emit hooks design: trigger on iu_lifecycle_log (not fn rewrite)
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
- 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_functiondefdiff before/after 11000x's apply confirms it. - 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.
- 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.
- Easy operator gating: flipping
piece_event_runtime.emit_enabledis 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: confirmspiece_event_runtime.emit_enabledis checked BEFOREINSERT INTO public.event_outboxin 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 wouldRAISE EXCEPTIONon 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.