KB-5596

dot-iu-cutter v0.5 — Lifecycle Enactment Design · Recommended fn_iu_enact Contract (G4 PASS) (doc 4 of 6)

33 min read Revision 1
dot-iu-cutterv0.5lifecycle-enactment-designrecommended-contractfn-iu-enactsecdef-canonical-writerfsm-draft-enacted-superseded-retirediu-lifecycle-vocabiu-lifecycle-logfn-iu-apply-edit-draft-patchtrg-iu-enacted-immuttrg-uv-enacted-immutdieu442026-05-20

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_governance row ACL is stricter than public; introducing a cross-schema FK requires REFERENCES privilege from any role that may insert into iu_lifecycle_log (only directus via SECDEF in this design, but still architecturally entangling).
  • The integrity invariant ("the referenced governance row exists") is validated inside fn_iu_enact via a SELECT probe before the INSERT.
  • Hard FK would also block iu_lifecycle_log writes 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
Back to Knowledge Hub 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