dot-iu-cutter v0.5 — Lifecycle Implementation Authoring · fn_iu_apply_edit_draft Patch Package (Bundle E) (doc 3 of 6)
dot-iu-cutter v0.5 — Lifecycle Implementation Authoring · fn_iu_apply_edit_draft Patch Package
doc 3 of 6 · 2026-05-20 · PATCH AUTHORED, NOT EXECUTED
phase : G2 (continued) — Bundle E patch outcome : PASS — full patched function body authored; pre/post-patch md5 capture defined; diff isolated to one block (lifecycle check) production_mutation : NONE
0. Why this patch exists
The current production public.fn_iu_apply_edit_draft body (md5
22875ce25b2e2d1751cc4f3d1757252e, 4144 chars, SECDEF) contains the
following block (verbatim, lines 23-26 of body):
SELECT count(DISTINCT lifecycle_status), min(lifecycle_status)
INTO v_lc_count, v_uv_lifecycle
FROM public.unit_version;
IF v_lc_count != 1 OR v_uv_lifecycle IS NULL OR btrim(v_uv_lifecycle)='' THEN
RETURN jsonb_build_object('status','lifecycle_ambiguous',
'guidance','Not uniquely determined.',
'next_action','stop_for_gpt_review');
END IF;
This is a GLOBAL count across ALL UV rows in the database. The
function uses v_uv_lifecycle (the alphabetic MIN of distinct values)
to stamp lifecycle_status on any NEW UV row it inserts.
Current production state: all 165 UV rows have lifecycle_status='draft',
so v_lc_count=1, the check passes, and new edit-draft applications
inherit 'draft'.
The instant Phase 7 enacts any UV (the 60 ICX-CONST anchor UVs),
the table will have 2+ distinct lifecycle_status values, the check
will fail, and every future fn_iu_apply_edit_draft call across the
entire database will return 'lifecycle_ambiguous' and refuse to
publish any edit. This is a HARD breakage of all in-place edit flows,
unrelated to the IU being edited.
This patch corrects the coupling: the new UV row inherits the
lifecycle_status of the IU's current version anchor (per-IU,
deterministic). Additionally, the patch refuses to apply an edit draft
when the base anchor is 'enacted' (forces the future supersede path
instead).
1. Pre/post-patch fingerprints
pre_patch:
proname : fn_iu_apply_edit_draft
args : p_draft_id uuid, p_actor text, p_review_note text
prosecdef : true
body_md5 : 22875ce25b2e2d1751cc4f3d1757252e
body_length : 4144
post_patch:
proname : fn_iu_apply_edit_draft (unchanged)
args : p_draft_id uuid, p_actor text, p_review_note text (unchanged)
prosecdef : true (unchanged)
body_md5 : (computed after CREATE OR REPLACE FUNCTION applies;
expected NOT EQUAL to pre_patch.body_md5)
body_length : ≈4140 ± 30 chars (small net change; lines ±5)
backup_capture (execution macro will do this BEFORE applying patch):
SELECT prosrc INTO :backup_body
FROM pg_proc
WHERE pronamespace='public'::regnamespace
AND proname='fn_iu_apply_edit_draft';
# store :backup_body in operational sidecar (encrypted) so rollback
# can reconstruct the pre-patch CREATE OR REPLACE statement.
2. The minimal diff (intent)
DELETE (4 lines around line 23-29 of body):
SELECT count(DISTINCT lifecycle_status),min(lifecycle_status) INTO v_lc_count,v_uv_lifecycle FROM public.unit_version;
IF v_lc_count!=1 OR v_uv_lifecycle IS NULL OR btrim(v_uv_lifecycle)='' THEN
RETURN jsonb_build_object('status','lifecycle_ambiguous','guidance','Not uniquely determined.','next_action','stop_for_gpt_review');
END IF;
INSERT (replacement block, ~8 lines):
v_uv_lifecycle := v_uv.lifecycle_status;
IF v_uv_lifecycle IS NULL OR btrim(v_uv_lifecycle) = '' THEN
RETURN jsonb_build_object('status','lifecycle_ambiguous',
'guidance','Current version anchor has empty lifecycle_status.',
'next_action','stop_for_gpt_review');
END IF;
IF v_uv_lifecycle = 'enacted' THEN
RETURN jsonb_build_object('status','base_version_enacted',
'guidance','Cannot apply edit-draft on enacted version; use supersede path.',
'next_action','fn_iu_create_edit_draft_for_supersede');
END IF;
UNUSED VARIABLE (declared but never assigned in new body):
v_lc_count int -- can be removed from DECLARE block; OR kept harmless
NOTHING ELSE CHANGES.
3. Full patched function body (CREATE OR REPLACE)
CREATE OR REPLACE FUNCTION public.fn_iu_apply_edit_draft(
p_draft_id uuid,
p_actor text,
p_review_note text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
v_draft record;
v_iu record;
v_uv record;
v_hash text;
v_next_seq int;
v_new_uv_id uuid;
v_uv_lifecycle text;
v_stale_count int;
v_inv jsonb;
v_comment_body text;
v_comment_id uuid;
BEGIN
IF p_draft_id IS NULL THEN
RETURN jsonb_build_object('status','invalid_input','field','draft_id',
'guidance','Required.','next_action','fix_input');
END IF;
IF btrim(COALESCE(p_actor,'')) = '' THEN
RETURN jsonb_build_object('status','invalid_input','field','actor',
'guidance','Required.','next_action','fix_input');
END IF;
SELECT * INTO v_draft FROM public.unit_edit_draft WHERE id = p_draft_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('status','draft_not_found','draft_id',p_draft_id,
'guidance','Check draft_id.','next_action','verify_draft_id');
END IF;
IF v_draft.draft_status != 'open' THEN
RETURN jsonb_build_object('status','draft_not_open','draft_id',p_draft_id,
'current_status',v_draft.draft_status,
'guidance','Only open.','next_action','fn_iu_create_edit_draft');
END IF;
SELECT * INTO v_iu FROM public.information_unit WHERE id = v_draft.unit_id FOR UPDATE;
IF v_iu.version_anchor_ref != v_draft.base_version_ref THEN
UPDATE public.unit_edit_draft SET draft_status='stale_base', stale_at=now()
WHERE id = p_draft_id;
RETURN jsonb_build_object('status','stale_base','draft_id',p_draft_id,
'guidance','Base changed.','next_action','fn_iu_create_edit_draft');
END IF;
SELECT * INTO v_uv FROM public.unit_version WHERE id = v_iu.version_anchor_ref;
v_hash := public.fn_content_hash(v_draft.draft_body);
IF v_hash != v_draft.draft_content_hash THEN
RETURN jsonb_build_object('status','draft_hash_mismatch','draft_id',p_draft_id,
'guidance','Corrupted.','next_action','fn_iu_create_edit_draft');
END IF;
IF v_hash = v_uv.content_hash AND v_draft.draft_title IS NULL THEN
RETURN jsonb_build_object('status','no_change',
'guidance','Identical.','next_action','none');
END IF;
-- ─── PATCH BEGINS ──────────────────────────────────────────────
-- Previously: SELECT count(DISTINCT lifecycle_status) FROM public.unit_version
-- (global coupling that breaks once any UV is non-'draft'.)
--
-- Now: inherit lifecycle_status from the CURRENT version anchor
-- (per-IU, deterministic). Refuse to publish on top of an enacted
-- base — those edits must travel through a future supersede path.
v_uv_lifecycle := v_uv.lifecycle_status;
IF v_uv_lifecycle IS NULL OR btrim(v_uv_lifecycle) = '' THEN
RETURN jsonb_build_object('status','lifecycle_ambiguous',
'guidance','Current version anchor has empty lifecycle_status.',
'next_action','stop_for_gpt_review');
END IF;
IF v_uv_lifecycle = 'enacted' THEN
RETURN jsonb_build_object('status','base_version_enacted',
'unit_id',v_iu.id,
'canonical_address',v_iu.canonical_address,
'guidance','Base version is enacted; in-place apply not allowed. Use supersede path.',
'next_action','fn_iu_create_edit_draft_for_supersede');
END IF;
-- ─── PATCH ENDS ────────────────────────────────────────────────
SELECT COALESCE(MAX(version_seq),0) + 1 INTO v_next_seq
FROM public.unit_version WHERE unit_id = v_iu.id;
PERFORM set_config('app.canonical_writer','fn_iu_apply_edit_draft', true);
INSERT INTO public.unit_version (id, unit_id, body, content_hash, version_seq,
lifecycle_status, created_by, created_at)
VALUES (gen_random_uuid(), v_iu.id, v_draft.draft_body, v_hash, v_next_seq,
v_uv_lifecycle, btrim(p_actor), now())
RETURNING id INTO v_new_uv_id;
UPDATE public.information_unit
SET version_anchor_ref = v_new_uv_id,
content_anchor_ref = v_new_uv_id::text,
updated_at = now(),
updated_by = btrim(p_actor),
identity_profile = CASE
WHEN v_draft.draft_title IS NOT NULL
THEN jsonb_set(identity_profile,'{title}', to_jsonb(v_draft.draft_title))
ELSE identity_profile
END
WHERE id = v_iu.id;
UPDATE public.unit_edit_draft
SET draft_status='applied', applied_at=now(),
applied_by=btrim(p_actor), applied_version_ref=v_new_uv_id
WHERE id = p_draft_id;
UPDATE public.unit_edit_draft
SET draft_status='stale_base', stale_at=now()
WHERE unit_id = v_iu.id AND id != p_draft_id AND draft_status='open';
GET DIAGNOSTICS v_stale_count = ROW_COUNT;
v_comment_body := COALESCE(p_review_note,
format('Applied as v%s by %s', v_next_seq, btrim(p_actor)));
INSERT INTO public.unit_edit_comment (draft_id, unit_id, author_ref, author_type,
comment_body, comment_kind)
VALUES (p_draft_id, v_iu.id, btrim(p_actor), 'system', v_comment_body, 'system')
RETURNING id INTO v_comment_id;
v_inv := public.fn_iu_verify_invariants(v_iu.canonical_address);
IF NOT (v_inv->>'all_pass')::boolean THEN
RAISE EXCEPTION 'invariant_failed: %', v_inv;
END IF;
RETURN jsonb_build_object(
'status','applied',
'draft_id',p_draft_id,
'version_id',v_new_uv_id,
'version_seq',v_next_seq,
'unit_id',v_iu.id,
'lifecycle_status',v_uv_lifecycle,
'stale_drafts_count',v_stale_count,
'comment_id',v_comment_id,
'invariants',v_inv,
'guidance','Published.',
'next_action','none');
END;
$$;
COMMENT ON FUNCTION public.fn_iu_apply_edit_draft(uuid,text,text) IS
'Apply an open edit-draft as a new unit_version. v0.5-M3a patch (2026-05-20): replaced global lifecycle_status coupling with per-anchor lookup; added base_version_enacted refusal to force supersede path on enacted bases.';
4. Behavior equivalence proof (when all UVs are 'draft')
preconditions (current production):
- every UV row in public.unit_version has lifecycle_status='draft'
- therefore v_uv.lifecycle_status = 'draft' for every v_iu
pre-patch behavior on a 'draft' base:
1. SELECT count(DISTINCT lifecycle_status) FROM public.unit_version
→ returns (1, 'draft')
2. v_lc_count=1, v_uv_lifecycle='draft' → check passes
3. proceeds to insert new UV with lifecycle_status='draft'
post-patch behavior on a 'draft' base:
1. v_uv_lifecycle := v_uv.lifecycle_status → 'draft'
2. v_uv_lifecycle is not NULL/empty → check passes
3. v_uv_lifecycle != 'enacted' → check passes
4. proceeds to insert new UV with lifecycle_status='draft'
⇒ pre-patch and post-patch behaviors are IDENTICAL for every existing
use case in production today. The patch is a NO-OP from the caller's
perspective until at least one UV enters a non-'draft' state.
5. Behavior differences (after at least one UV becomes non-'draft')
scenario A: edit-draft against a DRAFT base (the IU's anchor is draft):
pre-patch: v_lc_count > 1 (because some OTHER IU is enacted)
→ status='lifecycle_ambiguous' ❌ HARD BREAK
post-patch: v_uv.lifecycle_status='draft' on THIS IU
→ status='applied' ✓ proceeds correctly
scenario B: edit-draft against an ENACTED base:
pre-patch: if no other UVs are enacted (count=1, but the value is
'enacted'): proceeds with v_uv_lifecycle='enacted' →
inserts NEW UV with lifecycle_status='enacted' (no
enacted_at!) — silently FORGES an enacted version.
❌ SUBTLE CORRUPTION
if some UVs are draft + some are enacted (count=2):
→ status='lifecycle_ambiguous'.
post-patch: → status='base_version_enacted' ✓ refused with clear
next_action ('fn_iu_create_edit_draft_for_supersede')
⇒ post-patch behavior is strictly safer than pre-patch in BOTH
partial-enacted scenarios.
6. Risk assessment
test_coverage_needed:
T-I10 : edit-draft on draft IU still works (regression-pre-patch)
T-I11 : edit-draft on an enacted IU returns 'base_version_enacted'
(new behavior)
T-I12 : edit-draft against a stale base still returns 'stale_base'
(regression-pre-patch)
T-I13 : edit-draft with hash mismatch still returns 'draft_hash_mismatch'
ROLLBACK-only verification before exposing to live traffic:
- apply patch in a transaction
- dump pg_get_functiondef('public.fn_iu_apply_edit_draft'::regproc)
- diff against the documented body in this doc
- ROLLBACK
callers known to invoke fn_iu_apply_edit_draft:
- Directus server-side flow (workflow_admin role; via REST)
- public.fn_iu_save (auto_apply policy path)
- manual SELECT public.fn_iu_apply_edit_draft(...) ops calls
caller_impact:
- return-shape additions: 'base_version_enacted' is a NEW status value;
callers that switch on `status` MUST handle it or fall through
safely (most do; the existing 'no_change' / 'stale_base' /
'lifecycle_ambiguous' branches already require status-aware code).
- return-shape stability: all existing status values are preserved
('invalid_input','draft_not_found','draft_not_open','stale_base',
'draft_hash_mismatch','no_change','lifecycle_ambiguous','applied').
7. Pre-flight + post-patch verification probes
-- Pre-flight (run as context_pack_readonly):
SELECT md5(prosrc) AS pre_md5, length(prosrc) AS pre_len
FROM pg_proc
WHERE pronamespace='public'::regnamespace
AND proname='fn_iu_apply_edit_draft';
-- expect: pre_md5 = '22875ce25b2e2d1751cc4f3d1757252e', pre_len = 4144
-- Post-patch (immediately after CREATE OR REPLACE FUNCTION):
SELECT md5(prosrc) AS post_md5, length(prosrc) AS post_len, prosecdef AS secdef
FROM pg_proc
WHERE pronamespace='public'::regnamespace
AND proname='fn_iu_apply_edit_draft';
-- expect: post_md5 != pre_md5; secdef = true; length within ±100 of pre
ROLLBACK-only behavioral probe (run AFTER patch applies, BEFORE Phase 7 or any commit; assumes some test edit-draft exists OR using a manufactured one):
-- B-9 (post-patch, ROLLBACK-only): on a draft base, patch behavior unchanged
BEGIN;
-- (test setup: a known open draft id pointing to a draft IU base)
SELECT public.fn_iu_apply_edit_draft(
'<known_draft_id>', 'verify_probe', 'B-9 probe');
-- expect: status='applied' OR 'no_change' OR 'draft_hash_mismatch'
-- (any of the existing draft-base success/refusal statuses)
ROLLBACK;
-- B-10 (post-patch, manufactured enacted base): refuses with base_version_enacted
-- (can only run AFTER Phase 7 ; during this macro we cannot construct
-- an enacted base without violating forbidden boundaries; left as a
-- follow-on integration test under LIFECYCLE_ENACT_TEST_INTEGRATION=1)
8. Rollback procedure for Bundle E
-- if the patched function needs to be reverted:
-- 1. Restore from the operational sidecar :backup_body captured pre-patch:
CREATE OR REPLACE FUNCTION public.fn_iu_apply_edit_draft(
p_draft_id uuid, p_actor text, p_review_note text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $body$
<insert-prior-body-text-here>
$body$;
-- 2. Verify md5 matches pre-patch:
SELECT md5(prosrc) FROM pg_proc
WHERE pronamespace='public'::regnamespace
AND proname='fn_iu_apply_edit_draft';
-- expect: '22875ce25b2e2d1751cc4f3d1757252e'
rollback_constraint:
- rollback is SAFE only when no UV has been enacted yet (i.e. before
Phase 7).
- if rollback fires AFTER any UV is enacted: the pre-patch body's
global coupling will start returning 'lifecycle_ambiguous' for
every call. Caller surface degrades; consider whether rollback is
actually the right move vs forward-fix.
9. Patch ordering vs other bundles
constraint:
Bundle E MUST be applied BEFORE Phase 7 (the 60-IU enactment macro).
WITHOUT Bundle E, Phase 7 will leave fn_iu_apply_edit_draft in a
hard-broken state for every IU in the database.
flexibility:
Bundle E can be applied:
- BEFORE Bundles A..D (it has no dependency on iu_lifecycle_vocab/log
or fn_iu_enact);
- INTERLEAVED with Bundles A..D in either order;
- or AS THE LAST Bundle, immediately before Phase 7 runs.
Recommended ordering (design doc 05 §2):
Bundle A → B → C → D → E
Rationale: the function chain (vocab/log → triggers → enact → policy →
patch) reads top-down as a story; not a hard constraint.
10. G2 Bundle E disposition
G2_Bundle_E_fn_iu_apply_edit_draft_patch : PASS
production_mutation : NONE
delivered:
- exact diff identified
- full patched function body authored
- behavior-equivalence proof (current state)
- behavior-difference table (post-Phase-7 state)
- risk assessment + test coverage
- pre/post fingerprint capture
- rollback procedure
- patch ordering analysis
next:
- G3 — verification + rollback + compensation plan
[[dot-iu-cutter-v0-5-04-verification-rollback-compensation-plan-2026-05-20]]
Related KB documents in this package:
- [[dot-iu-cutter-v0-5-01-live-recheck-and-scope-lock-2026-05-20]]
- [[dot-iu-cutter-v0-5-02-fn-iu-enact-ddl-package-2026-05-20]]
- [[dot-iu-cutter-v0-5-04-verification-rollback-compensation-plan-2026-05-20]]
- [[dot-iu-cutter-v0-5-05-command-review-package-2026-05-20]]
- [[dot-iu-cutter-v0-5-06-final-implementation-authoring-report-2026-05-20]]