dot-iu-cutter v0.5 — Lifecycle Enactment Design · Recommended fn_iu_enact Contract (G4 PASS) (doc 4 of 6)
dot-iu-cutter v0.5 — Lifecycle Enactment Design · Recommended fn_iu_enact Contract
doc 4 of 6 · 2026-05-20 · DESIGN-ONLY ; NO DDL/PL/pgSQL EXECUTED
phase : G4 — recommended lifecycle enactment contract outcome : PASS — full contract written (signature, FSM, vocab, idempotency, audit linkage, preconditions, immutability triggers, in-scope patch) production_mutation : NONE
0. Inputs to this document
This contract assumes the proposed default rulings from [[dot-iu-cutter-v0-5-03-design-options-analysis-2026-05-20]] §6:
OQ-1 : APPLY fn_iu_apply_edit_draft patch (option a)
OQ-2 : SOFT-CHECK vocab inside function (option b)
OQ-3 : PYTHON LOOP one-tx-per-IU (option b)
OQ-4 : NEW public.iu_lifecycle_log (option a)
OQ-5 : HARD REQUIRE review_decision_id (option a)
OQ-6 : YES re-run verify_invariants (option a)
OQ-7 : WARN-ONLY on P-pub1/P-pub2 (option b)
If the sovereign ruling deviates from any of these, the contract is revised in a follow-on macro before implementation. Operational specifics (grants, verification scripts, rollback runbook) are in [[dot-iu-cutter-v0-5-05-grant-verification-rollback-plan-2026-05-20]].
1. Function signature
CREATE OR REPLACE FUNCTION public.fn_iu_enact(
p_canonical_address text,
p_actor text,
p_review_decision_id uuid,
p_target_lifecycle text DEFAULT 'enacted',
p_change_set_id uuid DEFAULT NULL,
p_reason text DEFAULT NULL,
p_tool_revision text DEFAULT NULL,
p_dry_run boolean DEFAULT false
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
owner : directus
security_definer : YES (runs as directus inside the function body;
sets app.canonical_writer='fn_iu_enact' to satisfy
trg_aa_iu_gateway_write_guard / trg_aa_uv_gateway_write_guard)
volatility : VOLATILE
search_path : pinned (pg_catalog, public)
parameter intent :
p_canonical_address : the IU identity to transition (REQUIRED)
p_actor : person/process responsible (REQUIRED;
stamped into updated_by + iu_lifecycle_log.performed_by)
p_review_decision_id : cutter_governance.review_decision.id ruling the
enactment (REQUIRED; HARD REQUIRE per OQ-5)
p_target_lifecycle : 'enacted' (default), 'superseded', 'retired'
— vocab-checked; only FSM-legal transitions accepted
p_change_set_id : OPTIONAL governance link to a cutter_governance
cut_change_set (e.g. a NEW change_set recorded
for enactment); soft-FK in log
p_reason : free-text rationale; stored in iu_lifecycle_log.reason
p_tool_revision : caller's revision string (mirror M2 write-VERIFY
tool_revision discipline); soft SoD with executor
p_dry_run : if true, run preflight+precondition+FSM check
and RETURN intended action without writing
2. Vocab — public.iu_lifecycle_vocab
2.1 Schema (mirrors tac_uv_lifecycle_vocab exactly)
CREATE TABLE public.iu_lifecycle_vocab (
code text NOT NULL,
name text NOT NULL,
description text,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
PRIMARY KEY (code)
);
2.2 Seed (idempotent)
INSERT INTO public.iu_lifecycle_vocab (code, name, description, sort_order) VALUES
('draft', 'Bản nháp', 'Mới tạo, chưa enacted; chỉnh sửa qua edit-draft.', 10),
('enacted', 'Đã ban hành', 'Đã ban hành qua fn_iu_enact + review_decision. Bất biến.', 20),
('superseded', 'Bị thay', 'Bị một enactment mới thay; giữ làm audit.', 30),
('retired', 'Đã rút', 'Đã rút khỏi sử dụng qua governance.', 40)
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
sort_order = EXCLUDED.sort_order,
updated_at = now();
2.3 FSM transition table (enforced inside fn_iu_enact)
| from \ to | draft | enacted | superseded | retired |
|---|---|---|---|---|
| draft | — (no-op) | OK | DENY | OK |
| enacted | DENY | DENY | OK (only via supersede fn) | OK |
| superseded | DENY | DENY | — (no-op) | OK |
| retired | DENY | DENY | DENY | — (no-op) |
allowed_transitions (this contract):
- draft → enacted (canonical promotion)
- draft → retired (abandon before enactment; future fn_iu_retire)
- enacted → superseded (replaced by a newer enacted version; future
fn_iu_supersede invoked from a NEW fn_iu_enact)
- enacted → retired (retire enacted; future fn_iu_retire)
- superseded → retired (retire superseded; future fn_iu_retire)
- any → same (no-op) (idempotent — returns 'already_<status>')
DENIED by FSM (RAISE EXCEPTION):
- enacted → draft
- superseded → draft / enacted
- retired → anything
For THIS scope (60 ICX-CONST initial enactment), only draft → enacted is
exercised. The other rows of the FSM are designed-but-not-implemented
beyond the FSM gate inside fn_iu_enact; their action handlers (mutate the
right rows, set retired_at, set superseded_by, etc.) are stubs returning
'transition_not_yet_implemented' until a follow-on macro authors them.
3. Audit log — public.iu_lifecycle_log
3.1 Schema
CREATE TABLE public.iu_lifecycle_log (
id uuid NOT NULL DEFAULT gen_random_uuid(),
unit_id uuid NOT NULL,
canonical_address text NOT NULL,
version_anchor_ref uuid, -- UV row at time of transition
from_status text NOT NULL,
to_status text NOT NULL,
transition_type text NOT NULL, -- e.g. 'enact', 'supersede', 'retire'
reason text,
performed_by text NOT NULL,
performed_at timestamp with time zone NOT NULL DEFAULT now(),
review_decision_id uuid, -- soft FK to cutter_governance.review_decision
change_set_id uuid, -- soft FK to cutter_governance.cut_change_set
tool_revision text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
PRIMARY KEY (id),
FOREIGN KEY (unit_id) REFERENCES public.information_unit(id)
);
CREATE INDEX ix_iu_lifecycle_log_unit ON public.iu_lifecycle_log (unit_id);
CREATE INDEX ix_iu_lifecycle_log_addr ON public.iu_lifecycle_log (canonical_address);
CREATE INDEX ix_iu_lifecycle_log_performed ON public.iu_lifecycle_log (performed_at);
CREATE INDEX ix_iu_lifecycle_log_review_dec ON public.iu_lifecycle_log (review_decision_id);
CREATE INDEX ix_iu_lifecycle_log_change_set ON public.iu_lifecycle_log (change_set_id);
3.2 Soft-FK rationale
review_decision_id and change_set_id reference rows in the
cutter_governance schema. Hard FK is not used because:
cutter_governancerow ACL is stricter thanpublic; introducing a cross-schema FK requiresREFERENCESprivilege from any role that may insert intoiu_lifecycle_log(onlydirectusvia SECDEF in this design, but still architecturally entangling).- The integrity invariant ("the referenced governance row exists") is
validated inside
fn_iu_enactvia a SELECT probe before the INSERT. - Hard FK would also block
iu_lifecycle_logwrites during any cutter_governance maintenance window.
The soft-FK shape is consistent with the M2 write-VERIFY linkage pattern (verify_result references change_set/manifest_envelope/review_decision/ executor_sig via UUID columns without cross-schema FK).
3.3 Row writes per call
One row per successful fn_iu_enact invocation. The row is INSERTed
inside the same transaction that updates information_unit and
unit_version, so the audit is atomic with the data write.
4. Immutability triggers
4.1 public.fn_iu_enacted_immut() + trg_iu_enacted_immut
Mirror of fn_law_enacted_immutable adapted for information_unit shape.
CREATE OR REPLACE FUNCTION public.fn_iu_enacted_immut() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF OLD.lifecycle_status = 'enacted' AND TG_OP = 'UPDATE' THEN
-- allow transitions to superseded/retired (the only legal lifecycle
-- changes OUT of 'enacted', enforced via fn_iu_enact FSM)
IF NEW.lifecycle_status IN ('superseded','retired') AND
NEW.lifecycle_status <> OLD.lifecycle_status THEN
RETURN NEW;
END IF;
-- everything else: locked-down content fields
IF (NEW.canonical_address IS DISTINCT FROM OLD.canonical_address)
OR (NEW.unit_kind IS DISTINCT FROM OLD.unit_kind)
OR (NEW.owner_ref IS DISTINCT FROM OLD.owner_ref)
OR (NEW.version_anchor_ref IS DISTINCT FROM OLD.version_anchor_ref)
OR (NEW.content_anchor_ref IS DISTINCT FROM OLD.content_anchor_ref)
OR (NEW.identity_profile IS DISTINCT FROM OLD.identity_profile)
OR (NEW.parent_or_container_ref IS DISTINCT FROM OLD.parent_or_container_ref)
THEN
RAISE EXCEPTION
'INV-ENACTED-IMMUT: cannot modify identity/anchor fields of enacted IU %',
OLD.id USING ERRCODE = 'check_violation';
END IF;
END IF;
IF TG_OP = 'DELETE' AND OLD.lifecycle_status = 'enacted' THEN
RAISE EXCEPTION
'INV-ENACTED-IMMUT: cannot delete enacted IU %',
OLD.id USING ERRCODE = 'check_violation';
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_iu_enacted_immut
BEFORE UPDATE OR DELETE ON public.information_unit
FOR EACH ROW EXECUTE FUNCTION public.fn_iu_enacted_immut();
trigger_naming : trg_iu_enacted_immut (NOT prefixed 'trg_aa_'; the 'aa'
prefix is reserved for the gateway-FIRST trigger ordering
pattern; fn_iu_enacted_immut runs AFTER the gateway so
no 'aa' prefix needed)
trigger_order : will fire AFTER trg_aa_iu_gateway_write_guard
(gateway is alphabetically first by 'aa' prefix);
this ordering is the intended composition: gateway
decides whether to allow the write, then immutability
layer decides if the write is semantically legal
4.2 public.fn_uv_enacted_immut() + trg_uv_enacted_immut
Verbatim adoption of fn_tac_enacted_immut, retargeted to public.unit_version:
CREATE OR REPLACE FUNCTION public.fn_uv_enacted_immut() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF OLD.lifecycle_status = 'enacted' AND TG_OP = 'UPDATE' THEN
-- allow transitions to superseded/retired only
IF NEW.lifecycle_status IN ('superseded','retired') AND
NEW.lifecycle_status <> OLD.lifecycle_status THEN
-- but body/title/etc. still immutable
IF (NEW.body IS DISTINCT FROM OLD.body)
OR (NEW.title IS DISTINCT FROM OLD.title)
OR (NEW.description IS DISTINCT FROM OLD.description)
OR (NEW.content_profile IS DISTINCT FROM OLD.content_profile)
OR (NEW.content_hash IS DISTINCT FROM OLD.content_hash)
THEN
RAISE EXCEPTION
'INV-ENACTED-IMMUT: enacted unit_version body/title/description/content_profile/content_hash are immutable even on retire/supersede; %',
OLD.id USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END IF;
IF (NEW.body IS DISTINCT FROM OLD.body)
OR (NEW.title IS DISTINCT FROM OLD.title)
OR (NEW.description IS DISTINCT FROM OLD.description)
OR (NEW.content_profile IS DISTINCT FROM OLD.content_profile)
OR (NEW.content_hash IS DISTINCT FROM OLD.content_hash)
OR (NEW.unit_id IS DISTINCT FROM OLD.unit_id)
OR (NEW.version_seq IS DISTINCT FROM OLD.version_seq)
THEN
RAISE EXCEPTION
'INV-ENACTED-IMMUT: cannot modify body/title/description/content_profile/content_hash/unit_id/version_seq of enacted unit_version %',
OLD.id USING ERRCODE = 'check_violation';
END IF;
END IF;
IF TG_OP = 'DELETE' AND OLD.lifecycle_status = 'enacted' THEN
RAISE EXCEPTION
'INV-ENACTED-IMMUT: cannot delete enacted unit_version %',
OLD.id USING ERRCODE = 'check_violation';
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
CREATE TRIGGER trg_uv_enacted_immut
BEFORE UPDATE OR DELETE ON public.unit_version
FOR EACH ROW EXECUTE FUNCTION public.fn_uv_enacted_immut();
key_difference_vs_fn_tac_enacted_immut :
- also locks content_hash, unit_id, version_seq (TAC's locks only
body/title/description/content_profile; production UV has more
identity fields)
- allows retire/supersede transitions but still locks body fields
in that path (TAC's analog implicitly relies on its FSM to not
re-write body during retire)
5. fn_iu_enact body — step-by-step contract
The function body executes the following steps in order. Each step has a deterministic failure mode and a deterministic JSON return shape on failure (the function never re-raises after step 1 except for the SQL EXCEPTION pattern used by all SECDEF writers in this repo).
step 0 : Input validation
- p_canonical_address: btrim != '' else RETURN status='invalid_input', field='canonical_address'
- p_actor: btrim != '' else RETURN status='invalid_input', field='actor'
- p_review_decision_id: NOT NULL else RETURN status='invalid_input', field='review_decision_id'
- p_target_lifecycle: must be in iu_lifecycle_vocab.code
(SELECT 1 FROM public.iu_lifecycle_vocab WHERE code=p_target_lifecycle)
else RETURN status='invalid_target_lifecycle'
step 1 : Lookup IU
- SELECT id, lifecycle_status, version_anchor_ref, identity_profile
INTO v_iu FROM public.information_unit
WHERE canonical_address = btrim(p_canonical_address)
FOR UPDATE;
- IF NOT FOUND THEN RETURN status='iu_not_found'
step 2 : Idempotency check
- IF v_iu.lifecycle_status = p_target_lifecycle THEN
RETURN status='already_<target>', no-op=true, no rows written;
step 3 : FSM check
- allowed pair {OLD,NEW} per §2.3; else RETURN status='fsm_denied',
from=v_iu.lifecycle_status, to=p_target_lifecycle
step 4 : Invariant precheck (per OQ-6)
- v_inv := public.fn_iu_verify_invariants(p_canonical_address);
- IF NOT (v_inv->>'all_pass')::boolean THEN
RETURN status='invariant_failed', invariants=v_inv;
step 5 : OQ-7 publication-profile soft check (WARN-ONLY this scope)
- IF v_iu.identity_profile->>'publication_authority_ref' IS NULL
OR btrim(v_iu.identity_profile->>'publication_authority_ref')='' THEN
v_warnings := v_warnings || 'P-pub1_missing_publication_authority_ref';
- IF v_iu.identity_profile->>'publication_type_ref' IS NULL
OR btrim(v_iu.identity_profile->>'publication_type_ref')='' THEN
v_warnings := v_warnings || 'P-pub2_missing_publication_type_ref';
(warnings flow into iu_lifecycle_log.metadata; do NOT block this scope)
step 6 : Governance-link presence probe
- PERFORM 1 FROM cutter_governance.review_decision
WHERE id = p_review_decision_id;
- IF NOT FOUND THEN RETURN status='review_decision_not_found'
- IF p_change_set_id IS NOT NULL THEN
PERFORM 1 FROM cutter_governance.cut_change_set
WHERE id = p_change_set_id;
IF NOT FOUND THEN RETURN status='change_set_not_found'
NOTE: function runs as directus (SECDEF) so it can SELECT
cutter_governance even if cutter_exec cannot.
step 7 : Dry-run short-circuit
- IF p_dry_run THEN RETURN status='plan_ok',
from=v_iu.lifecycle_status, to=p_target_lifecycle,
iu_id=v_iu.id, version_anchor_ref=v_iu.version_anchor_ref,
invariants=v_inv, warnings=v_warnings,
would_write_rows={information_unit: 1, unit_version: 1,
iu_lifecycle_log: 1};
step 8 : Advisory lock
- PERFORM pg_advisory_xact_lock(hashtext('iu_enact:'||v_iu.id::text));
(serializes concurrent enactments on the SAME IU; concurrent
enactments on DIFFERENT IUs proceed in parallel)
step 9 : Set canonical-writer marker (txn-local)
- PERFORM set_config('app.canonical_writer','fn_iu_enact',true);
step 10 : UPDATE information_unit
- UPDATE public.information_unit
SET lifecycle_status = p_target_lifecycle,
updated_by = btrim(p_actor)
-- updated_at automatically set by trg_iu_updated_at
WHERE id = v_iu.id;
step 11 : UPDATE unit_version (the current version anchor)
- IF p_target_lifecycle = 'enacted' THEN
UPDATE public.unit_version
SET lifecycle_status = 'enacted',
enacted_at = now(),
updated_at = now()
WHERE id = v_iu.version_anchor_ref;
- For 'superseded'/'retired': set lifecycle_status only;
keep enacted_at as-is (already populated when it was enacted).
step 12 : INSERT iu_lifecycle_log
- INSERT INTO public.iu_lifecycle_log
(unit_id, canonical_address, version_anchor_ref,
from_status, to_status, transition_type, reason,
performed_by, review_decision_id, change_set_id,
tool_revision, metadata)
VALUES
(v_iu.id, btrim(p_canonical_address), v_iu.version_anchor_ref,
v_iu.lifecycle_status, p_target_lifecycle,
CASE p_target_lifecycle
WHEN 'enacted' THEN 'enact'
WHEN 'superseded' THEN 'supersede'
WHEN 'retired' THEN 'retire'
ELSE 'transition' END,
p_reason, btrim(p_actor),
p_review_decision_id, p_change_set_id,
p_tool_revision,
jsonb_build_object(
'warnings', v_warnings,
'invariants', v_inv,
'app_canonical_writer','fn_iu_enact'))
RETURNING id INTO v_log_id;
step 13 : Post-write structural re-check (cheap probe)
- SELECT lifecycle_status INTO v_iu_after FROM public.information_unit WHERE id=v_iu.id;
- SELECT lifecycle_status, enacted_at INTO v_uv_after FROM public.unit_version WHERE id=v_iu.version_anchor_ref;
- IF v_iu_after.lifecycle_status <> p_target_lifecycle THEN
RAISE EXCEPTION 'fn_iu_enact post-write mismatch on IU: %', v_iu.id;
(UV post-condition: lifecycle_status matches; enacted_at non-null
when target='enacted')
step 14 : RETURN success
- RETURN jsonb_build_object(
'status', 'enacted', -- or 'superseded'/'retired'
'iu_id', v_iu.id,
'canonical_address', btrim(p_canonical_address),
'version_anchor_ref', v_iu.version_anchor_ref,
'from_status', v_iu.lifecycle_status,
'to_status', p_target_lifecycle,
'transition_type', v_transition_type,
'log_id', v_log_id,
'enacted_at', v_uv_after.enacted_at,
'review_decision_id', p_review_decision_id,
'change_set_id', p_change_set_id,
'warnings', v_warnings,
'invariants', v_inv
);
5.1 Status enum (return shape)
success_states:
enacted -- target=enacted, transition performed
superseded -- target=superseded, transition performed
retired -- target=retired, transition performed
already_enacted -- idempotent no-op
already_superseded
already_retired
plan_ok -- p_dry_run=true, all checks passed
failure_states (no rows written):
invalid_input -- field-level validation
invalid_target_lifecycle -- p_target_lifecycle not in vocab
iu_not_found -- canonical_address not in IU table
fsm_denied -- transition not allowed by FSM
invariant_failed -- fn_iu_verify_invariants reported failure
review_decision_not_found
change_set_not_found
transition_not_yet_implemented -- supersede/retire handler not yet authored
6. In-scope patch — fn_iu_apply_edit_draft global-coupling fix (OQ-1)
The existing function contains (verbatim, see G1 §6.1):
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', ...);
END IF;
This breaks the instant any UV becomes non-'draft'. The patch:
-- Pre-patch query (REMOVE):
-- 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 ...
-- Post-patch query (INSERT in its place):
SELECT lifecycle_status INTO v_uv_lifecycle
FROM public.unit_version
WHERE id = v_iu.version_anchor_ref;
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;
-- Additional guard (NEW): refuse to edit-draft an already-enacted UV.
-- Edits to an enacted version must come through a FUTURE
-- fn_iu_create_edit_draft → fn_iu_apply_edit_draft → fn_iu_enact
-- sequence that produces a NEW version_seq, leaving the prior enacted
-- version as 'superseded'.
IF v_uv_lifecycle = 'enacted' THEN
RETURN jsonb_build_object('status','base_version_enacted',
'guidance','Cannot apply edit-draft on enacted version; create supersession path.',
'next_action','fn_iu_create_edit_draft_for_supersede');
END IF;
patch_blast_radius:
- touches : public.fn_iu_apply_edit_draft body only
- md5_change : YES (function body changes; pre/post body md5 captured
in [[dot-iu-cutter-v0-5-05-grant-verification-rollback-plan-2026-05-20]] §3)
- behavior_change_visible_to_callers : NO when all UVs are 'draft'
(current state); the post-patch
function behaves identically.
AFTER any enactment, the new
per-anchor semantics kick in.
- test_coverage_needed : 2 new tests
(a) edit-draft on a draft IU still works
(b) edit-draft on an enacted IU returns 'base_version_enacted'
7. dot_config namespace — iu_enact.*
INSERT INTO public.dot_config (key, value, description, updated_at) VALUES
('iu_enact.canonical_function',
'public.fn_iu_enact(text,text,uuid,text,uuid,text,text,boolean)',
'Canonical lifecycle enactment function for information_unit', now()),
('iu_enact.mode', 'enforced',
'Enforcement mode for lifecycle enactment', now()),
('iu_enact.target_default', 'enacted',
'Default p_target_lifecycle value', now()),
('iu_enact.vocab_table', 'public.iu_lifecycle_vocab',
'Source of valid lifecycle_status codes', now()),
('iu_enact.log_table', 'public.iu_lifecycle_log',
'Per-call audit log table', now()),
('iu_enact.allow_no_review_decision', 'false',
'If true, fn_iu_enact accepts NULL p_review_decision_id (NOT recommended)', now()),
('iu_enact.policy_doc_path',
'knowledge/dev/laws/dieu44-trien-khai/v0.5-lifecycle-enactment-design/dot-iu-cutter-v0.5-04-recommended-lifecycle-enactment-contract-2026-05-20.md',
'Lifecycle enactment design doc', now()),
('iu_enact.readme_path',
'knowledge/dev/laws/dieu44-trien-khai/readme/iu-lifecycle-enactment-readme.md',
'Operator README (to be authored alongside implementation macro)', now())
ON CONFLICT (key) DO UPDATE
SET value = EXCLUDED.value, description = EXCLUDED.description, updated_at = now();
AND the single-row UPDATE for the gateway:
UPDATE public.dot_config
SET value = 'fn_iu_create,fn_iu_apply_edit_draft,fn_iu_enact',
updated_at = now()
WHERE key = 'iu_create.gateway.allowed_marker_values';
allowed_marker_values:
before : 'fn_iu_create,fn_iu_apply_edit_draft'
after : 'fn_iu_create,fn_iu_apply_edit_draft,fn_iu_enact'
verification (post-UPDATE):
SELECT value FROM public.dot_config WHERE key='iu_create.gateway.allowed_marker_values';
expected: 'fn_iu_create,fn_iu_apply_edit_draft,fn_iu_enact'
8. Caller integration — cutter_agent/lifecycle_enact_adapter.py
module_path : cutter_agent/lifecycle_enact_adapter.py
class_name : ProdIuLifecycleEnactAdapter
exports :
- ProdIuLifecycleEnactAdapter(connection_provider)
- .enact_one(canonical_address, actor, review_decision_id,
target='enacted', change_set_id=None, reason=None,
dry_run=False) -> EnactOutcome
- .enact_batch(addresses: List[str], actor, review_decision_id,
change_set_id=None, reason=None, dry_run=False,
stop_on_first_error=False) -> List[EnactOutcome]
- .verify_enactment(canonical_address) -> VerifyResult
# SELECT-only re-probe ; can also run pre-flight
behavior:
- one fn_iu_enact call per IU
- one psycopg2 transaction per call (BEGIN ; SELECT public.fn_iu_enact(...);
if jsonb status starts with success → COMMIT; else → ROLLBACK)
- returned status enum mapped to EnactOutcome dataclass
- per-IU logging: address, log_id (when present), status, warnings
- stop_on_first_error=False is the default (continue past per-IU
failures); stop_on_first_error=True for stricter modes
testing:
- mock psycopg2 cursor with fixture rows; tests cover all status enum
- integration test gated by env LARK_TEST_INTEGRATION-style flag
LIFECYCLE_ENACT_TEST_INTEGRATION=1 (NOT enabled by default)
CLI entry to add to cutprod_canonical.py:
sub_command : enact
arguments :
--canonical-address-pattern text (e.g. 'ICX-CONST%')
(OR --canonical-address-file path)
--actor text (required)
--review-decision-id uuid (required)
--change-set-id uuid (optional)
--target-lifecycle text (default 'enacted')
--dry-run flag (default off; runs enact_batch with
dry_run=True; reports plan; exits 0)
--connection-provider-module pkg.mod:callable (parity with prod CUT)
exit_codes :
0 : all-pass (or dry-run plan-ok for every IU)
1 : at least one IU failed; details in stdout JSONL
2 : preflight aborted (gateway not configured, vocab missing, etc.)
3 : connection/auth failure
4 : forbidden boundary breached (e.g. policy_doc fingerprint mismatch)
5 : reserved
9. Idempotency story
re-call_with_same_inputs:
- fn_iu_enact is idempotent on (canonical_address, target) for status=='already_<target>'
- DOES NOT re-write iu_lifecycle_log row (no new row for no-op);
the first transition's log row remains the source of truth
re-call_after_partial_failure:
- Because each fn_iu_enact is wrapped in a single SQL transaction at the
function level, partial states do not persist on failure.
- Python-adapter level: if a network drop happens AFTER COMMIT but before
the result reaches python, retry will hit the idempotent 'already_<target>'
branch and proceed.
key_invariants:
- exactly one iu_lifecycle_log row PER successful transition
- never two log rows for the same (unit_id, from, to) tuple
enforced by app-level check; could be made a partial UNIQUE index
(unit_id, from_status, to_status, performed_at::date) but that's brittle
10. Impact on the 60 ICX-CONST rows (this scope)
target_population : SELECT id, canonical_address FROM public.information_unit
WHERE canonical_address LIKE 'ICX-CONST%';
(60 rows; 158 total IUs in table)
per-IU transition : draft → enacted
per-IU writes (atomic transaction):
1 row UPDATE on information_unit:
lifecycle_status: 'draft' → 'enacted'
updated_at: now()
updated_by: <p_actor>
1 row UPDATE on unit_version (the version_seq=1 row for this IU):
lifecycle_status: 'draft' → 'enacted'
enacted_at: NULL → now()
updated_at: now()
1 row INSERT into iu_lifecycle_log
total rows written for full 60-IU enactment:
- information_unit : 60 UPDATEs
- unit_version : 60 UPDATEs
- iu_lifecycle_log : 60 INSERTs
total persisted rows : 180
untouched:
- the 98 pre-existing non-ICX-CONST IUs remain at lifecycle_status='draft'
- the 105 pre-existing UVs remain at lifecycle_status='draft', enacted_at NULL
- birth_registry untouched
- cutter_governance untouched (no leg-B for enactment in this scope;
OQ-3 keeps batching off and OQ-5 requires the review_decision row to
pre-exist)
11. Tests required (sketch)
unit tests (no DB):
T-U1 : fn_iu_enact step-0 input validation (8 cases)
T-U2 : FSM matrix encoding (16 cases incl. all denied)
T-U3 : ProdIuLifecycleEnactAdapter status mapping (10 cases)
T-U4 : CLI argparse + dispatch (4 cases)
integration tests (gated by env flag):
T-I1 : fn_iu_enact idempotent draft→enacted same address (2 calls)
T-I2 : fn_iu_enact dry_run reports 'plan_ok' without persistence
T-I3 : fn_iu_enact iu_not_found returns refusal
T-I4 : fn_iu_enact invariant_failed when invariants broken (use
ROLLBACK-only setup that violates anchors)
T-I5 : fn_iu_enact review_decision_not_found
T-I6 : trg_iu_enacted_immut blocks UPDATE on enacted IU body fields
T-I7 : trg_iu_enacted_immut allows UPDATE on lifecycle_status to
'superseded' / 'retired' but blocks 'draft'
T-I8 : trg_uv_enacted_immut blocks UPDATE on enacted UV body
T-I9 : trg_uv_enacted_immut blocks DELETE on enacted UV
T-I10 : fn_iu_apply_edit_draft post-patch — edit-draft on draft IU works
T-I11 : fn_iu_apply_edit_draft post-patch — edit-draft on enacted IU
returns 'base_version_enacted'
T-I12 : gateway allows fn_iu_enact UPDATE; blocks raw UPDATE
T-I13 : 60 ICX-CONST IUs all transition via adapter.enact_batch in
dry_run=True (plan_ok for every IU)
12. G4 disposition
G4_recommended_contract : PASS
production_mutation : NONE
delivered:
- fn_iu_enact signature + body contract
- iu_lifecycle_vocab table + 4-row seed
- iu_lifecycle_log table + 5 indexes
- fn_iu_enacted_immut + trg_iu_enacted_immut
- fn_uv_enacted_immut + trg_uv_enacted_immut
- fn_iu_apply_edit_draft in-scope patch
- dot_config iu_enact.* namespace seed + gateway UPDATE
- cutter_agent/lifecycle_enact_adapter.py module shape + CLI surface
- idempotency + audit linkage + FSM
- test plan (13 unit + 13 integration)
next:
- G5 — grant/verification/rollback plan
[[dot-iu-cutter-v0-5-05-grant-verification-rollback-plan-2026-05-20]]
Related KB documents:
- [[dot-iu-cutter-v0-5-01-live-lifecycle-survey-2026-05-20]]
- [[dot-iu-cutter-v0-5-02-existing-lifecycle-docs-code-review-2026-05-20]]
- [[dot-iu-cutter-v0-5-03-design-options-analysis-2026-05-20]]
- [[dot-iu-cutter-v0-5-05-grant-verification-rollback-plan-2026-05-20]] — next
- [[dot-iu-cutter-v0-5-06-final-lifecycle-design-report-2026-05-20]] — final