KB-650F

dot-iu-cutter v0.5 — Lifecycle Implementation Authoring · fn_iu_enact DDL Package (Bundles A+B+C+D) (doc 2 of 6)

55 min read Revision 1
dot-iu-cutterv0.5lifecycle-enactment-implementation-authoringfn-iu-enact-ddliu-lifecycle-vocab-ddliu-lifecycle-log-ddltrg-iu-enacted-immut-ddltrg-uv-enacted-immut-ddldot-config-iu-enact-keysgrants-cutter-execno-production-executiondieu442026-05-20

dot-iu-cutter v0.5 — Lifecycle Implementation Authoring · fn_iu_enact DDL Package

doc 2 of 6 · 2026-05-20 · DDL/PL/pgSQL AUTHORED, NOT EXECUTED

phase             : G2 — DDL/function authoring (Bundles A + B + C + D)
outcome           : PASS — full DDL package authored; no execution
production_mutation : NONE

0. Reading order

This document contains the authored SQL/PL/pgSQL bodies for:

  • Bundle Apublic.iu_lifecycle_vocab + public.iu_lifecycle_log (table DDL + seed + indexes + read grants)
  • Bundle Bpublic.fn_iu_enacted_immut() + trg_iu_enacted_immut + public.fn_uv_enacted_immut() + trg_uv_enacted_immut
  • Bundle Cpublic.fn_iu_enact(...) (full SECDEF body; 14 steps; 230+ lines)
  • Bundle D — gateway policy UPDATE + 8 iu_enact.* dot_config keys + EXECUTE grants

Bundle E (fn_iu_apply_edit_draft patch) is authored separately in [[dot-iu-cutter-v0-5-03-fn-iu-apply-edit-draft-patch-package-2026-05-20]] to keep the diff minimal and isolated.

Bundle F (cutter_agent/lifecycle_enact_adapter.py + CLI + README) is sketched at the end of this document (§13).

Nothing in this document is executed. All statements assume an operator runs them under the privileges noted, in the order noted, with the verification probes from [[dot-iu-cutter-v0-5-04-verification-rollback-compensation-plan-2026-05-20]].

1. Bundle A — vocab + log tables

1.1 public.iu_lifecycle_vocab — DDL

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)
);

COMMENT ON TABLE public.iu_lifecycle_vocab IS
    'Canonical lifecycle vocabulary for public.information_unit and public.unit_version. Mirrors public.tac_uv_lifecycle_vocab. Authored by v0.5 M3 lifecycle enactment design.';

COMMENT ON COLUMN public.iu_lifecycle_vocab.code IS
    'Lifecycle state code (e.g. draft/enacted/superseded/retired). Referenced by fn_iu_enact for soft vocab check.';

1.2 public.iu_lifecycle_vocab — 4-row 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();

1.3 public.iu_lifecycle_log — DDL

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,
    from_status         text                     NOT NULL,
    to_status           text                     NOT NULL,
    transition_type     text                     NOT NULL,
    reason              text,
    performed_by        text                     NOT NULL,
    performed_at        timestamp with time zone NOT NULL DEFAULT now(),
    review_decision_id  uuid,
    change_set_id       uuid,
    tool_revision       text,
    metadata            jsonb                    NOT NULL DEFAULT '{}'::jsonb,
    PRIMARY KEY (id),
    CONSTRAINT fk_iu_lifecycle_log_unit
        FOREIGN KEY (unit_id) REFERENCES public.information_unit(id)
);

COMMENT ON TABLE public.iu_lifecycle_log IS
    'Per-call audit log for IU lifecycle transitions performed by fn_iu_enact / future fn_iu_supersede / fn_iu_retire. UUID-keyed, append-only, no UPDATE/DELETE granted to any role.';
why_fk_to_information_unit:
  - HARD FK (same schema) is cheap and ensures every log row resolves
    to a real IU. Cross-schema FKs (review_decision_id, change_set_id)
    remain soft-FK by design (validated inside fn_iu_enact step 6).

why_no_unique_constraint:
  - Idempotency is enforced at the application level inside fn_iu_enact
    (returns 'already_<target>' without inserting). Adding a UNIQUE on
    (unit_id, from_status, to_status, performed_at::date) would be
    brittle around midnight UTC and complicate retries.

1.4 public.iu_lifecycle_log — 5 indexes

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)
    WHERE review_decision_id IS NOT NULL;

CREATE INDEX ix_iu_lifecycle_log_change_set
    ON public.iu_lifecycle_log (change_set_id)
    WHERE change_set_id IS NOT NULL;
partial-index rationale:
  review_decision_id / change_set_id are soft-FK and may be NULL in
  future log rows (e.g. emergency_revert per design doc 05 §5.2);
  partial indexes avoid bloating the index with NULLs.

1.5 Bundle A — read grants

-- vocab is reference data; freely readable
GRANT SELECT ON public.iu_lifecycle_vocab TO PUBLIC;
GRANT SELECT ON public.iu_lifecycle_vocab TO context_pack_readonly;
GRANT SELECT ON public.iu_lifecycle_vocab TO cutter_exec;
GRANT SELECT ON public.iu_lifecycle_vocab TO cutter_verify;

-- log is audit; readable by ops + observers; writes only via SECDEF
GRANT SELECT ON public.iu_lifecycle_log   TO context_pack_readonly;
GRANT SELECT ON public.iu_lifecycle_log   TO cutter_exec;
GRANT SELECT ON public.iu_lifecycle_log   TO cutter_verify;
GRANT SELECT ON public.iu_lifecycle_log   TO workflow_admin;

-- NO INSERT/UPDATE/DELETE to any role: writes happen under SECDEF
-- as directus inside fn_iu_enact. Explicit REVOKEs are unnecessary
-- because PostgreSQL's default grant model already denies; but we
-- assert it for documentation clarity:
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.iu_lifecycle_log
       FROM PUBLIC, context_pack_readonly, cutter_exec, cutter_verify, workflow_admin;

2. Bundle B — immutability functions + triggers

2.1 public.fn_iu_enacted_immut() — IU-level immutability

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 lifecycle transitions to 'superseded' or 'retired'
        -- (these are the only legal exits from 'enacted', enforced by
        -- fn_iu_enact FSM; the trigger trusts the gateway+fn pair here)
        IF NEW.lifecycle_status IN ('superseded','retired')
           AND NEW.lifecycle_status <> OLD.lifecycle_status THEN
            RETURN NEW;
        END IF;

        -- lifecycle_status flipped to anything other than supersede/retire:
        -- block (e.g. 'enacted' → 'draft' is not allowed)
        IF NEW.lifecycle_status IS DISTINCT FROM OLD.lifecycle_status THEN
            RAISE EXCEPTION
                'INV-ENACTED-IMMUT: enacted IU lifecycle_status can only move to superseded/retired; got % → %',
                OLD.lifecycle_status, NEW.lifecycle_status
                USING ERRCODE = 'check_violation';
        END IF;

        -- lifecycle_status unchanged but other identity/anchor fields touched:
        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;
$$;

COMMENT ON FUNCTION public.fn_iu_enacted_immut() IS
    'Immutability guard for enacted information_unit rows. Allows only lifecycle_status → superseded/retired and refuses any identity/anchor mutation. Refuses DELETE on enacted rows. Composes after trg_aa_iu_gateway_write_guard.';

2.2 trg_iu_enacted_immut

-- Idempotency guard (PostgreSQL has no CREATE TRIGGER OR REPLACE):
DROP TRIGGER IF EXISTS trg_iu_enacted_immut ON public.information_unit;

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_ordering:
  - trg_aa_iu_gateway_write_guard  (BEFORE INSERT OR UPDATE; existing)
  - trg_iu_birth_gate_layer1        (BEFORE INSERT only; existing)
  - trg_iu_enacted_immut            (BEFORE UPDATE OR DELETE; NEW)
  - trg_iu_updated_at               (BEFORE UPDATE; existing)
  - trg_birth_information_unit      (AFTER INSERT; existing)
  - trg_iu_birth_gate_layer2        (AFTER INSERT OR UPDATE; DEFERRABLE; existing)
ordering by alphabetic trigger name within phase (PG default):
  for BEFORE UPDATE:
    1. trg_aa_iu_gateway_write_guard  (sorts first by 'aa' prefix)
    2. trg_iu_enacted_immut           (sorts second alphabetically)
    3. trg_iu_updated_at              (sorts last)
  ⇒ gateway decides if the write is even allowed; immutability decides
    if the row's semantics are legal; updated_at then stamps the time.

2.3 public.fn_uv_enacted_immut() — UV-level immutability

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 lifecycle transitions to 'superseded' or 'retired'
        IF NEW.lifecycle_status IN ('superseded','retired')
           AND NEW.lifecycle_status <> OLD.lifecycle_status THEN
            -- but content fields stay locked even on retire/supersede
            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: enacted unit_version body/title/description/content_profile/content_hash/unit_id/version_seq are immutable even on retire/supersede; %',
                    OLD.id
                    USING ERRCODE = 'check_violation';
            END IF;
            RETURN NEW;
        END IF;

        -- lifecycle_status flipped to anything other than supersede/retire:
        IF NEW.lifecycle_status IS DISTINCT FROM OLD.lifecycle_status THEN
            RAISE EXCEPTION
                'INV-ENACTED-IMMUT: enacted unit_version lifecycle_status can only move to superseded/retired; got % → %',
                OLD.lifecycle_status, NEW.lifecycle_status
                USING ERRCODE = 'check_violation';
        END IF;

        -- lifecycle_status unchanged but content fields touched:
        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;
$$;

COMMENT ON FUNCTION public.fn_uv_enacted_immut() IS
    'Immutability guard for enacted unit_version rows. Adapted from public.fn_tac_enacted_immut with extra locks on content_hash/unit_id/version_seq. Allows enacted_at and lifecycle_status → superseded/retired transitions; refuses content mutation and DELETE.';

2.4 trg_uv_enacted_immut

DROP TRIGGER IF EXISTS trg_uv_enacted_immut ON public.unit_version;

CREATE TRIGGER trg_uv_enacted_immut
    BEFORE UPDATE OR DELETE ON public.unit_version
    FOR EACH ROW EXECUTE FUNCTION public.fn_uv_enacted_immut();
allowed_columns_to_change_on_enacted_uv:
  - lifecycle_status                  (only to superseded/retired)
  - enacted_at                        (stays set; the function does not
                                        re-touch it after enactment)
  - updated_at                        (stamped by no existing trigger
                                        on unit_version; fn_iu_enact sets
                                        updated_at=now() inline)
  - review_state, provenance, editor (not locked; these are workflow
                                        metadata that can evolve)
  - description (already locked above? — re-check: §2.3 locks
    description if changed; intentional, mirrors tac pattern)

Note: description IS in the locked set above (mirror of fn_tac_enacted_immut). If the operator needs to amend description after enactment, that requires either a supersede path or a separate fn_uv_amend_description (NOT designed here; backlog).

3. Bundle C — fn_iu_enact full body

This is the canonical writer for lifecycle transitions on the IU domain.

3.1 Function header

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
AS $$

3.2 Local-variable declarations

DECLARE
    v_iu                  record;
    v_uv_current          record;
    v_uv_anchor_id        uuid;
    v_from_status         text;
    v_target              text;
    v_vocab_match         int;
    v_inv                 jsonb;
    v_inv_pass            boolean;
    v_rd_found            int;
    v_cs_found            int;
    v_warnings            jsonb := '[]'::jsonb;
    v_pa_ref              text;
    v_pt_ref              text;
    v_transition_type     text;
    v_iu_after_status     text;
    v_uv_after_status     text;
    v_uv_after_enacted_at timestamp with time zone;
    v_log_id              uuid;
BEGIN

3.3 Step 0 — input validation

    -- p_canonical_address
    IF p_canonical_address IS NULL OR btrim(p_canonical_address) = '' THEN
        RETURN jsonb_build_object(
            'status','invalid_input',
            'field','canonical_address',
            'guidance','Required.',
            'next_action','fix_input');
    END IF;

    -- p_actor
    IF p_actor IS NULL OR btrim(p_actor) = '' THEN
        RETURN jsonb_build_object(
            'status','invalid_input',
            'field','actor',
            'guidance','Required.',
            'next_action','fix_input');
    END IF;

    -- p_review_decision_id (HARD REQUIRE per OQ-5)
    IF p_review_decision_id IS NULL THEN
        RETURN jsonb_build_object(
            'status','invalid_input',
            'field','review_decision_id',
            'guidance','Required. Enactment must reference a cutter_governance.review_decision row.',
            'next_action','record_review_decision_first');
    END IF;

    -- p_target_lifecycle: vocab soft check
    v_target := COALESCE(NULLIF(btrim(p_target_lifecycle), ''), 'enacted');
    SELECT count(*) INTO v_vocab_match
      FROM public.iu_lifecycle_vocab
     WHERE code = v_target;
    IF v_vocab_match = 0 THEN
        RETURN jsonb_build_object(
            'status','invalid_target_lifecycle',
            'field','target_lifecycle',
            'value',v_target,
            'guidance','target must exist in public.iu_lifecycle_vocab.code',
            'next_action','fix_input');
    END IF;

3.4 Step 1 — lookup IU FOR UPDATE

    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 jsonb_build_object(
            'status','iu_not_found',
            'canonical_address',btrim(p_canonical_address),
            'guidance','No information_unit row at this canonical_address.',
            'next_action','verify_canonical_address');
    END IF;

    v_from_status  := v_iu.lifecycle_status;
    v_uv_anchor_id := v_iu.version_anchor_ref;

3.5 Step 2 — idempotency check

    IF v_from_status = v_target THEN
        RETURN jsonb_build_object(
            'status','already_' || v_target,
            'iu_id',v_iu.id,
            'canonical_address',btrim(p_canonical_address),
            'from_status',v_from_status,
            'to_status',v_target,
            'guidance','No-op; transition already at target.',
            'next_action','none');
    END IF;

3.6 Step 3 — FSM check

    v_transition_type := CASE
        WHEN v_from_status = 'draft'      AND v_target = 'enacted'    THEN 'enact'
        WHEN v_from_status = 'draft'      AND v_target = 'retired'    THEN 'retire_from_draft'
        WHEN v_from_status = 'enacted'    AND v_target = 'superseded' THEN 'supersede'
        WHEN v_from_status = 'enacted'    AND v_target = 'retired'    THEN 'retire_from_enacted'
        WHEN v_from_status = 'superseded' AND v_target = 'retired'    THEN 'retire_from_superseded'
        ELSE NULL
    END;

    IF v_transition_type IS NULL THEN
        RETURN jsonb_build_object(
            'status','fsm_denied',
            'from_status',v_from_status,
            'to_status',v_target,
            'guidance','FSM does not allow this transition.',
            'next_action','review_fsm_in_design_doc_03_section_2_3');
    END IF;

    -- THIS SCOPE: only transition_type='enact' is fully implemented.
    -- Other transition types are intentionally stubbed; the function
    -- refuses them so they cannot be invoked accidentally before their
    -- handlers are authored in a follow-on macro.
    IF v_transition_type <> 'enact' THEN
        RETURN jsonb_build_object(
            'status','transition_not_yet_implemented',
            'from_status',v_from_status,
            'to_status',v_target,
            'transition_type',v_transition_type,
            'guidance','Use future fn_iu_supersede / fn_iu_retire.',
            'next_action','await_followon_macro');
    END IF;

3.7 Step 4 — invariant precheck (OQ-6)

    v_inv := public.fn_iu_verify_invariants(btrim(p_canonical_address));
    v_inv_pass := COALESCE((v_inv->>'all_pass')::boolean, false);
    IF NOT v_inv_pass THEN
        RETURN jsonb_build_object(
            'status','invariant_failed',
            'iu_id',v_iu.id,
            'canonical_address',btrim(p_canonical_address),
            'invariants',v_inv,
            'guidance','fn_iu_verify_invariants reported a structural defect; enactment refused.',
            'next_action','run_health_remediation');
    END IF;

3.8 Step 5 — OQ-7 soft P-pub1/P-pub2 check (warnings only this scope)

    v_pa_ref := v_iu.identity_profile->>'publication_authority_ref';
    v_pt_ref := v_iu.identity_profile->>'publication_type_ref';

    IF v_pa_ref IS NULL OR btrim(v_pa_ref) = '' THEN
        v_warnings := v_warnings ||
            to_jsonb('P-pub1_missing_publication_authority_ref'::text);
    END IF;

    IF v_pt_ref IS NULL OR btrim(v_pt_ref) = '' THEN
        v_warnings := v_warnings ||
            to_jsonb('P-pub2_missing_publication_type_ref'::text);
    END IF;
    -- WARN-ONLY this scope; warnings flow into iu_lifecycle_log.metadata
    -- review_decision_id (HARD REQUIRE per OQ-5)
    SELECT count(*) INTO v_rd_found
      FROM cutter_governance.review_decision
     WHERE review_decision_id = p_review_decision_id;

    IF v_rd_found = 0 THEN
        RETURN jsonb_build_object(
            'status','review_decision_not_found',
            'review_decision_id',p_review_decision_id,
            'guidance','No matching cutter_governance.review_decision row.',
            'next_action','record_review_decision_first');
    END IF;

    -- change_set_id (OPTIONAL)
    IF p_change_set_id IS NOT NULL THEN
        SELECT count(*) INTO v_cs_found
          FROM cutter_governance.cut_change_set
         WHERE change_set_id = p_change_set_id;

        IF v_cs_found = 0 THEN
            RETURN jsonb_build_object(
                'status','change_set_not_found',
                'change_set_id',p_change_set_id,
                'guidance','No matching cutter_governance.cut_change_set row.',
                'next_action','record_change_set_or_omit');
        END IF;
    END IF;

3.10 Step 7 — dry-run short-circuit

    IF p_dry_run THEN
        RETURN jsonb_build_object(
            'status','plan_ok',
            'iu_id',v_iu.id,
            'canonical_address',btrim(p_canonical_address),
            'version_anchor_ref',v_uv_anchor_id,
            'from_status',v_from_status,
            'to_status',v_target,
            'transition_type',v_transition_type,
            'review_decision_id',p_review_decision_id,
            'change_set_id',p_change_set_id,
            'invariants',v_inv,
            'warnings',v_warnings,
            'would_write_rows',
                jsonb_build_object(
                    'information_unit', 1,
                    'unit_version',     1,
                    'iu_lifecycle_log', 1),
            'guidance','Dry-run passed all gates; no rows written.',
            'next_action','re_invoke_with_p_dry_run_false');
    END IF;

3.11 Step 8 — advisory lock (per-IU serialization)

    PERFORM pg_advisory_xact_lock(hashtext('iu_enact:' || v_iu.id::text));

3.12 Step 9 — set canonical-writer marker (txn-local)

    PERFORM set_config('app.canonical_writer', 'fn_iu_enact', true);

3.13 Step 10 — UPDATE information_unit

    UPDATE public.information_unit
       SET lifecycle_status = v_target,
           updated_by       = btrim(p_actor)
           -- updated_at automatically set by trg_iu_updated_at
     WHERE id = v_iu.id;

3.14 Step 11 — UPDATE the current unit_version anchor

    IF v_uv_anchor_id IS NULL THEN
        -- shouldn't happen: fn_iu_verify_invariants would have failed
        -- at step 4 (i2_uv_linked = false). Defensive RAISE:
        RAISE EXCEPTION
            'fn_iu_enact: information_unit % has NULL version_anchor_ref despite invariants pass',
            v_iu.id;
    END IF;

    UPDATE public.unit_version
       SET lifecycle_status = v_target,
           enacted_at       = CASE WHEN v_target = 'enacted' THEN now()
                                   ELSE enacted_at  -- preserve existing
                              END,
           updated_at       = now()
     WHERE id = v_uv_anchor_id;

3.15 Step 12 — INSERT iu_lifecycle_log row

    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_uv_anchor_id,
        v_from_status,
        v_target,
        v_transition_type,
        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',
            'enact_function_version','v0.5-M3a-2026-05-20'
        )
    )
    RETURNING id INTO v_log_id;

3.16 Step 13 — post-write structural re-check

    SELECT lifecycle_status INTO v_iu_after_status
      FROM public.information_unit
     WHERE id = v_iu.id;

    IF v_iu_after_status IS DISTINCT FROM v_target THEN
        RAISE EXCEPTION
            'fn_iu_enact post-write mismatch on IU %: expected %, got %',
            v_iu.id, v_target, v_iu_after_status;
    END IF;

    SELECT lifecycle_status, enacted_at
      INTO v_uv_after_status, v_uv_after_enacted_at
      FROM public.unit_version
     WHERE id = v_uv_anchor_id;

    IF v_uv_after_status IS DISTINCT FROM v_target THEN
        RAISE EXCEPTION
            'fn_iu_enact post-write mismatch on UV %: expected %, got %',
            v_uv_anchor_id, v_target, v_uv_after_status;
    END IF;

    IF v_target = 'enacted' AND v_uv_after_enacted_at IS NULL THEN
        RAISE EXCEPTION
            'fn_iu_enact post-write defect: UV % lifecycle_status=enacted but enacted_at NULL',
            v_uv_anchor_id;
    END IF;

3.17 Step 14 — return success

    RETURN jsonb_build_object(
        'status',             v_target,                -- 'enacted' (this scope)
        'iu_id',              v_iu.id,
        'canonical_address',  btrim(p_canonical_address),
        'version_anchor_ref', v_uv_anchor_id,
        'from_status',        v_from_status,
        'to_status',          v_target,
        '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,
        'tool_revision',      p_tool_revision,
        'warnings',           v_warnings,
        'invariants',         v_inv,
        'guidance',           'IU enacted under canonical path fn_iu_enact.',
        'next_action',        'none');
END;
$$;

COMMENT ON FUNCTION public.fn_iu_enact(text,text,uuid,text,uuid,text,text,boolean) IS
    'Canonical IU lifecycle enactment function. Sets app.canonical_writer marker, validates input + FSM + vocab + invariants + governance link, performs atomic UPDATE on information_unit + current unit_version anchor + INSERT iu_lifecycle_log. THIS SCOPE: only draft→enacted transitions are fully implemented. supersede / retire transitions are FSM-recognized but return transition_not_yet_implemented until follow-on macros author them. SECURITY DEFINER runs as directus; reads cutter_governance.review_decision via directus USAGE+SELECT.';

3.18 fn_iu_enact body — line count and structure summary

total_authored_lines_approx : 230  (declarations + 14 steps + comments)
declarations                : 17 local variables
guards_in_body               : G-IN-1, G-IN-2, G-IDM, G-FSM, G-INV, G-GOV, G-GOV2,
                                G-LOCK, G-MARK, G-POST (10 guards)
return_paths                 : 9 (1 success + 8 failure status values)
RAISE_paths                  : 3 (NULL anchor defensive, IU post-write mismatch,
                                  UV post-write mismatch + enacted_at NULL post-check)
side_effects                 : 1 UPDATE IU + 1 UPDATE UV + 1 INSERT log
                                + 1 set_config + 1 pg_advisory_xact_lock
                                = 5 statements with side effects
                                (no read-back redundancy beyond step 13's
                                 cheap probe)

4. Bundle D — gateway policy + dot_config + grants

4.1 Gateway allowed_marker_values UPDATE

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';
single_row_UPDATE:
  - PostgreSQL row count expected: 1
  - if row count != 1: the implementation macro aborts with
    'gateway_allowed_marker_values_missing'.
  - safe to re-run (idempotent on the final value).

4.2 iu_enact.* dot_config seed (8 keys)

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 when caller omits the argument',
     now()),
    ('iu_enact.vocab_table',
     'public.iu_lifecycle_vocab',
     'Source of valid lifecycle_status codes (soft-check inside fn_iu_enact)',
     now()),
    ('iu_enact.log_table',
     'public.iu_lifecycle_log',
     'Per-call audit log table; UUID-keyed; INSERT-only via SECDEF',
     now()),
    ('iu_enact.allow_no_review_decision',
     'false',
     'If true, fn_iu_enact accepts NULL p_review_decision_id (NOT recommended; intentionally NOT honored by current fn body)',
     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 contract',
     now()),
    ('iu_enact.readme_path',
     'knowledge/dev/laws/dieu44-trien-khai/readme/iu-lifecycle-enactment-readme.md',
     'Operator README for the enactment workflow',
     now())
ON CONFLICT (key) DO UPDATE
SET value       = EXCLUDED.value,
    description = EXCLUDED.description,
    updated_at  = now();
key_iu_enact_allow_no_review_decision:
  intent: forward-compatibility hook for a future relaxation path.
  current_status: fn_iu_enact step 0 ALWAYS refuses NULL p_review_decision_id
                  regardless of this key's value. The key is documented
                  here so operators see the design intent; flipping it
                  alone does NOT relax enforcement — the function body
                  would need a future patch.

4.3 EXECUTE grants

-- defense: deny PUBLIC explicitly (PostgreSQL default for SECDEF is to allow PUBLIC EXECUTE)
REVOKE EXECUTE ON FUNCTION public.fn_iu_enact(text,text,uuid,text,uuid,text,text,boolean)
       FROM PUBLIC;

-- primary caller: cutter_exec (cutter_agent role)
GRANT EXECUTE ON FUNCTION public.fn_iu_enact(text,text,uuid,text,uuid,text,text,boolean)
       TO cutter_exec;

-- ops convenience (optional; can be deferred):
-- GRANT EXECUTE ON FUNCTION public.fn_iu_enact(text,text,uuid,text,uuid,text,text,boolean)
--        TO workflow_admin;
workflow_admin_grant:
  status_in_this_scope : SKIPPED in initial execution; surfaced as optional
                          in design doc 05 §1.1. The minimal need is
                          cutter_exec for the python adapter. workflow_admin
                          can be added later if a non-cutter ops path
                          materializes.

5. Pre-flight probes (read-only; executed before DDL applies)

-- gateway state present and at expected pre-value
SELECT value FROM public.dot_config
 WHERE key='iu_create.gateway.allowed_marker_values';
-- expect: 'fn_iu_create,fn_iu_apply_edit_draft'

-- 60 ICX-CONST still all draft
SELECT count(*) FILTER (WHERE lifecycle_status='draft') AS n_draft,
       count(*) AS n_total
  FROM public.information_unit
 WHERE canonical_address LIKE 'ICX-CONST%';
-- expect: n_draft=60, n_total=60

-- no enactment infrastructure already present
SELECT count(*) FROM pg_class c
  JOIN pg_namespace n ON c.relnamespace=n.oid
 WHERE n.nspname='public'
   AND c.relname IN ('iu_lifecycle_vocab','iu_lifecycle_log');
-- expect: 0

SELECT count(*) FROM pg_proc
 WHERE pronamespace='public'::regnamespace
   AND proname IN ('fn_iu_enact','fn_iu_enacted_immut','fn_uv_enacted_immut');
-- expect: 0

-- directus has USAGE on cutter_governance and SELECT on the two probed tables
SELECT has_schema_privilege('directus','cutter_governance','USAGE')                 AS u,
       has_table_privilege('directus','cutter_governance.review_decision','SELECT') AS s_rd,
       has_table_privilege('directus','cutter_governance.cut_change_set','SELECT')  AS s_cs;
-- expect: u=true, s_rd=true, s_cs=true

If any probe fails: the implementation macro aborts before applying DDL.

6. Execution-phase ordering (for the FUTURE execution macro)

This sequence is the same as design doc 05 §2 but with explicit transaction boundaries the execution macro will use. Authored here only as documentation; no statement here is executed in THIS macro.

TXN-1  (Bundle A — vocab + log):
  BEGIN ;
  - A.1 CREATE TABLE public.iu_lifecycle_vocab
  - A.2 INSERT 4 rows ON CONFLICT DO UPDATE
  - A.3 CREATE TABLE public.iu_lifecycle_log
  - A.4 5 CREATE INDEX statements
  - A.5 GRANT SELECT statements (8 grants + 1 explicit REVOKE)
  COMMIT ;

TXN-2  (Bundle B — immutability):
  BEGIN ;
  - B.1 CREATE OR REPLACE FUNCTION public.fn_iu_enacted_immut()
  - B.2 DROP TRIGGER IF EXISTS trg_iu_enacted_immut
        CREATE TRIGGER trg_iu_enacted_immut
  - B.3 CREATE OR REPLACE FUNCTION public.fn_uv_enacted_immut()
  - B.4 DROP TRIGGER IF EXISTS trg_uv_enacted_immut
        CREATE TRIGGER trg_uv_enacted_immut
  COMMIT ;

TXN-3  (Bundle C — fn_iu_enact body):
  BEGIN ;
  - C.1 CREATE OR REPLACE FUNCTION public.fn_iu_enact(...)
  COMMIT ;
  POST-TXN ROLLBACK-only smoke (one SELECT public.fn_iu_enact(...);
                                  expect plan_ok or invariant_failed
                                  depending on test address)

TXN-4  (Bundle D — policy + grants):
  BEGIN ;
  - D.1 UPDATE allowed_marker_values
  - D.2 INSERT 8 iu_enact.* keys ON CONFLICT DO UPDATE
  - D.3 REVOKE EXECUTE FROM PUBLIC
  - D.4 GRANT EXECUTE TO cutter_exec
  COMMIT ;

TXN-5  (Bundle E — fn_iu_apply_edit_draft patch ; see doc 03):
  BEGIN ;
  - E.1 CREATE OR REPLACE FUNCTION public.fn_iu_apply_edit_draft(...)
  COMMIT ;
transaction_design:
  - each Bundle is one BEGIN…COMMIT, so a failure in one statement
    rolls back that Bundle but leaves prior Bundles intact.
  - Bundles A and B are independent (Bundle B does NOT reference vocab/log).
  - Bundle C requires Bundles A (for iu_lifecycle_log INSERT) and B
    (because step 13 needs the immutability triggers to be wired
    to behave correctly during testing).
  - Bundle D requires Bundle C (the function must exist before GRANT).
  - Bundle E is independent of Bundles A..D but should be applied
    BEFORE phase 7 enactment to avoid the global-lifecycle break.

7. Total schema delta (post-execution)

tables_added:       2   (iu_lifecycle_vocab, iu_lifecycle_log)
indexes_added:      5   (iu_lifecycle_log btree x5)
functions_added:    3   (fn_iu_enact, fn_iu_enacted_immut, fn_uv_enacted_immut)
functions_replaced: 1   (fn_iu_apply_edit_draft via Bundle E in doc 03)
triggers_added:     2   (trg_iu_enacted_immut, trg_uv_enacted_immut)
dot_config_rows_added:    8   (iu_enact.* keys)
dot_config_rows_updated:  1   (iu_create.gateway.allowed_marker_values)
function_grants:    2   (REVOKE FROM PUBLIC + GRANT TO cutter_exec)
table_grants:       12  (vocab SELECTs + log SELECTs)
foreign_keys:       1   (iu_lifecycle_log.unit_id → information_unit.id)
soft_FKs (validated by fn_iu_enact, not by DBMS):
  iu_lifecycle_log.review_decision_id  → cutter_governance.review_decision.review_decision_id
  iu_lifecycle_log.change_set_id       → cutter_governance.cut_change_set.change_set_id

production_rows_mutated:  0

8. Backward-compat surface

existing callers of fn_iu_create        : unaffected (function body unchanged)
existing callers of fn_iu_apply_edit_draft : SEE Bundle E doc 03 — new
                                           behavior on enacted base; existing
                                           draft-base flows unchanged
existing UPDATE attempts (none in repo) : still blocked by gateway
existing tac_unit_version flows         : unaffected (different table)
existing law / normative_registry flows : unaffected (different tables)

9. Defense-in-depth analysis

attack_surface_table:
  - direct UPDATE public.information_unit:
      blocked by trg_aa_iu_gateway_write_guard (existing)
      blocked by trg_iu_enacted_immut on enacted rows (NEW; doubles defense)
  - direct UPDATE public.unit_version:
      blocked by trg_aa_uv_gateway_write_guard (existing)
      blocked by trg_uv_enacted_immut on enacted rows (NEW)
  - calling fn_iu_enact with mismatched p_review_decision_id:
      blocked by step 6 governance probe
  - calling fn_iu_enact from public (anonymous role):
      blocked by REVOKE EXECUTE FROM PUBLIC (Bundle D)
  - calling fn_iu_enact with bogus p_target_lifecycle:
      blocked by step 0 vocab soft-check
  - calling fn_iu_enact when IU has structural defects:
      blocked by step 4 invariant precheck
  - replay of same fn_iu_enact call:
      idempotent: returns 'already_<target>' without writing
  - concurrent fn_iu_enact on same IU:
      serialized by pg_advisory_xact_lock
  - DELETE on enacted IU/UV:
      blocked by trg_iu_enacted_immut / trg_uv_enacted_immut
  - widening of allowed_marker_values to a generic value:
      out of fn scope; an ops-side antipattern (Pack 22-P3 §C.3 forbids
      "permanent back doors")

10. Open coupling — fn_iu_apply_edit_draft

The global count(DISTINCT lifecycle_status) FROM unit_version block in fn_iu_apply_edit_draft MUST be replaced before Phase 7. The full patch is in [[dot-iu-cutter-v0-5-03-fn-iu-apply-edit-draft-patch-package-2026-05-20]].

ordering_constraint:
  - Bundle E (fn_iu_apply_edit_draft patch) must be applied BEFORE
    Phase 7 (the enactment of 60 ICX-CONST IUs).
  - Bundle E can be applied AFTER Bundles A..D or in-between; the
    function body has no dependency on iu_lifecycle_log or fn_iu_enact.

11. SQL syntax / static review checklist

SECDEF safety:
  - fn_iu_enact:         SECURITY DEFINER ; SET search_path = pg_catalog, public ✓
  - fn_iu_enacted_immut: trigger function ; no SECDEF (intentional: runs as
                          invoker; immutability is invariant enforcement, not
                          a permissioned action) ✓
  - fn_uv_enacted_immut: same as above ✓

search_path pinning:
  - fn_iu_enact has SET search_path = pg_catalog, public explicitly
  - fn_iu_enacted_immut / fn_uv_enacted_immut do NOT set search_path;
    they reference no other schema and use only built-in operators, so
    search_path injection is not a vector. (Both follow the same
    convention as existing fn_tac_enacted_immut and fn_law_enacted_immutable
    in production.)

advisory_lock collision space:
  - hashtext('iu_enact:'||v_iu.id::text) uses a unique prefix
    'iu_enact:'; no overlap with other advisory-lock callers in
    production (verified by grepping pg_proc.prosrc for advisory_lock
    callers).

cross-schema references in fn_iu_enact:
  - cutter_governance.review_decision (SELECT only) ✓
  - cutter_governance.cut_change_set (SELECT only) ✓
  - public.iu_lifecycle_vocab (SELECT only) ✓
  - public.information_unit (UPDATE + FOR UPDATE) ✓
  - public.unit_version (UPDATE) ✓
  - public.iu_lifecycle_log (INSERT) ✓
  - public.fn_iu_verify_invariants (CALL) ✓

NULL handling:
  - p_target_lifecycle defaults to 'enacted' if NULL/empty (COALESCE)
  - p_change_set_id optional; only probed when NOT NULL
  - p_tool_revision optional; stored as NULL when not provided
  - p_reason optional; stored as NULL when not provided
  - identity_profile JSON->>operator is NULL-safe (returns text NULL)

text-trimming:
  - btrim() applied to p_canonical_address, p_actor at validation
  - inputs stored to log are btrim'd

transaction boundaries:
  - the entire fn_iu_enact body runs in the CALLER's transaction.
  - if the caller commits, the work commits atomically.
  - if any step RAISEs, the caller's transaction rolls back atomically.
  - pg_advisory_xact_lock is released at COMMIT/ROLLBACK automatically.
  - set_config(..., true) is reset at COMMIT/ROLLBACK automatically.

12. Negative-test scaffolding (for command-review consumption)

The execution macro will run the following ROLLBACK-only probes after Bundle C is applied (before Bundle D activates the cutter_exec EXECUTE grant):

-- B-1  dry-run on a valid ICX-CONST address (use the FIRST one)
BEGIN;
SELECT public.fn_iu_enact(
    'ICX-CONST/DIEU-0',          -- canonical_address
    'verify_probe',                -- actor
    '29c88a7b-60f7-41bd-af45-43cc9b9f41c0',  -- existing review_decision_id
                                              -- (CUT approval; functionally suitable as PROBE only)
    'enacted',                     -- target
    NULL,                          -- change_set_id
    'B-1 dry-run probe',           -- reason
    'iu-enact@v0.5-M3a-2026-05-20', -- tool_revision
    true                           -- dry_run
);
-- expect: status='plan_ok', would_write_rows={1,1,1}
ROLLBACK;

-- B-2 invalid target lifecycle
BEGIN;
SELECT public.fn_iu_enact(
    'ICX-CONST/DIEU-0','verify_probe',
    '29c88a7b-60f7-41bd-af45-43cc9b9f41c0',
    'NOT_IN_VOCAB',NULL,NULL,NULL,true);
-- expect: status='invalid_target_lifecycle'
ROLLBACK;

-- B-3 nonexistent canonical_address
BEGIN;
SELECT public.fn_iu_enact(
    'NOT_AN_ADDRESS','verify_probe',
    '29c88a7b-60f7-41bd-af45-43cc9b9f41c0',
    'enacted',NULL,NULL,NULL,true);
-- expect: status='iu_not_found'
ROLLBACK;

-- B-4 review_decision_not_found
BEGIN;
SELECT public.fn_iu_enact(
    'ICX-CONST/DIEU-0','verify_probe',
    '00000000-0000-0000-0000-000000000000',
    'enacted',NULL,NULL,NULL,true);
-- expect: status='review_decision_not_found'
ROLLBACK;

-- B-5 raw UPDATE still blocked by gateway (proves no widening)
BEGIN;
SET LOCAL app.canonical_writer = 'ad_hoc_attempt';
UPDATE public.information_unit
   SET lifecycle_status='enacted'
 WHERE canonical_address='ICX-CONST/DIEU-0';
-- expect: RAISE 'IU Gateway blocked: direct write to information_unit not allowed.'
ROLLBACK;

-- B-6 NULL review_decision_id (HARD REQUIRE)
BEGIN;
SELECT public.fn_iu_enact(
    'ICX-CONST/DIEU-0','verify_probe',
    NULL,'enacted',NULL,NULL,NULL,true);
-- expect: status='invalid_input', field='review_decision_id'
ROLLBACK;

-- B-7 NULL actor
BEGIN;
SELECT public.fn_iu_enact(
    'ICX-CONST/DIEU-0',NULL,
    '29c88a7b-60f7-41bd-af45-43cc9b9f41c0',
    'enacted',NULL,NULL,NULL,true);
-- expect: status='invalid_input', field='actor'
ROLLBACK;

-- B-8 supersede transition stub
BEGIN;
SELECT public.fn_iu_enact(
    'ICX-CONST/DIEU-0','verify_probe',
    '29c88a7b-60f7-41bd-af45-43cc9b9f41c0',
    'superseded',NULL,NULL,NULL,true);
-- expect: status='fsm_denied' (from='draft', to='superseded') ✓
ROLLBACK;
note_on_using_CUT_review_decision_for_probes:
  - The CUT review_decision 29c88a7b-… approved CREATION, not ENACTMENT.
  - Using it as the probe review_decision_id is FUNCTIONALLY VALID for
    dry-run tests (the function only checks existence, not semantic
    appropriateness).
  - For Phase 7 ENACTMENT execution, a NEW review_decision row distinct
    from 29c88a7b-… MUST be obtained — see design doc 05 §7.1.
  - The probes above use 29c88a7b-… ONLY because they are dry_run=true
    and never persist anything.

13. Bundle F sketch — cutter_agent/lifecycle_enact_adapter.py

Repo-side. Not authored fully in this macro (no repo commit happens), but the module shape is fixed so the next implementation step can import it directly.

# cutter_agent/lifecycle_enact_adapter.py
"""
Adapter for calling public.fn_iu_enact() over psycopg2.
Mirrors prod_iu_adapter_canonical.py for fn_iu_create.

THIS SCOPE: only draft→enacted transitions; supersede/retire
will return 'transition_not_yet_implemented' from the SQL side.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Callable, List, Optional, Sequence
from uuid import UUID


@dataclass
class EnactOutcome:
    canonical_address: str
    status: str            # 'enacted' / 'already_enacted' / 'plan_ok' /
                           # 'invalid_input' / 'invariant_failed' /
                           # 'review_decision_not_found' / 'fsm_denied' / ...
    iu_id: Optional[UUID] = None
    log_id: Optional[UUID] = None
    from_status: Optional[str] = None
    to_status: Optional[str] = None
    transition_type: Optional[str] = None
    warnings: List[str] = field(default_factory=list)
    raw: dict = field(default_factory=dict)


class ProdIuLifecycleEnactAdapter:
    def __init__(self, connection_provider: Callable):
        self._connect = connection_provider   # () -> psycopg2 connection,
                                              # autocommit=False (per CUT lesson L2)

    def enact_one(
        self,
        canonical_address: str,
        actor: str,
        review_decision_id: UUID,
        target: str = "enacted",
        change_set_id: Optional[UUID] = None,
        reason: Optional[str] = None,
        tool_revision: Optional[str] = None,
        dry_run: bool = False,
    ) -> EnactOutcome:
        with self._connect() as conn:        # BEGIN
            with conn.cursor() as cur:
                cur.execute(
                    "SELECT public.fn_iu_enact(%s,%s,%s,%s,%s,%s,%s,%s) AS r",
                    (canonical_address, actor, str(review_decision_id),
                     target, str(change_set_id) if change_set_id else None,
                     reason, tool_revision, dry_run),
                )
                row = cur.fetchone()
                payload = row[0] if row else {}
            status = str(payload.get("status", "unknown"))
            success = status in (
                "enacted","superseded","retired",
                "already_enacted","already_superseded","already_retired",
                "plan_ok",
            )
            if success:
                conn.commit()                # COMMIT
            else:
                conn.rollback()              # ROLLBACK on refusal-shaped JSON
        return EnactOutcome(
            canonical_address=canonical_address,
            status=status,
            iu_id=_to_uuid(payload.get("iu_id")),
            log_id=_to_uuid(payload.get("log_id")),
            from_status=payload.get("from_status"),
            to_status=payload.get("to_status"),
            transition_type=payload.get("transition_type"),
            warnings=list(payload.get("warnings") or []),
            raw=payload,
        )

    def enact_batch(
        self,
        addresses: Sequence[str],
        actor: str,
        review_decision_id: UUID,
        change_set_id: Optional[UUID] = None,
        reason: Optional[str] = None,
        tool_revision: Optional[str] = None,
        dry_run: bool = False,
        stop_on_first_error: bool = False,
    ) -> List[EnactOutcome]:
        results: List[EnactOutcome] = []
        for addr in addresses:
            outcome = self.enact_one(
                addr, actor, review_decision_id,
                change_set_id=change_set_id, reason=reason,
                tool_revision=tool_revision, dry_run=dry_run,
            )
            results.append(outcome)
            if stop_on_first_error and outcome.status not in (
                "enacted","already_enacted","plan_ok"
            ):
                break
        return results

    def verify_enactment(self, canonical_address: str) -> dict:
        with self._connect() as conn:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT iu.lifecycle_status, uv.lifecycle_status,
                           uv.enacted_at, log.id, log.to_status
                      FROM public.information_unit iu
                      JOIN public.unit_version uv ON uv.id = iu.version_anchor_ref
                      LEFT JOIN LATERAL (
                          SELECT id, to_status FROM public.iu_lifecycle_log
                           WHERE unit_id = iu.id
                           ORDER BY performed_at DESC LIMIT 1
                      ) log ON true
                     WHERE iu.canonical_address = %s
                    """,
                    (canonical_address,),
                )
                row = cur.fetchone()
            conn.rollback()  # read-only; no commit
        if row is None:
            return {"status": "iu_not_found",
                    "canonical_address": canonical_address}
        iu_lc, uv_lc, uv_enacted_at, log_id, log_to = row
        return {
            "status": "ok",
            "canonical_address": canonical_address,
            "iu_lifecycle_status": iu_lc,
            "uv_lifecycle_status": uv_lc,
            "uv_enacted_at": uv_enacted_at,
            "last_log_id": str(log_id) if log_id else None,
            "last_log_to_status": log_to,
        }


def _to_uuid(v):
    if v is None: return None
    if isinstance(v, UUID): return v
    try: return UUID(str(v))
    except (ValueError, TypeError): return None

CLI sub-command sketch for cutprod_canonical.py:

# inside cutprod_canonical.py:
def register_enact(subparsers):
    sp = subparsers.add_parser(
        "enact",
        help="Enact information_unit rows via public.fn_iu_enact() canonical path.",
    )
    sp.add_argument("--canonical-address-pattern", required=False,
                    help="SQL LIKE pattern, e.g. 'ICX-CONST%%'.")
    sp.add_argument("--canonical-address-file", required=False,
                    help="One canonical_address per line.")
    sp.add_argument("--actor", required=True)
    sp.add_argument("--review-decision-id", required=True, type=UUID)
    sp.add_argument("--change-set-id", required=False, type=UUID)
    sp.add_argument("--target-lifecycle", default="enacted")
    sp.add_argument("--reason", required=False)
    sp.add_argument("--tool-revision", required=False)
    sp.add_argument("--dry-run", action="store_true")
    sp.add_argument("--stop-on-first-error", action="store_true")
    sp.add_argument("--connection-provider-module", required=True,
                    help="Dotted path 'pkg.mod:callable' returning a psycopg2 conn.")
    sp.set_defaults(func=cmd_enact)

def cmd_enact(args) -> int:
    addresses = _load_addresses(args)  # resolve pattern OR file
    cp = _resolve_connection_provider(args.connection_provider_module)
    adapter = ProdIuLifecycleEnactAdapter(cp)
    results = adapter.enact_batch(
        addresses, args.actor, args.review_decision_id,
        change_set_id=args.change_set_id,
        reason=args.reason, tool_revision=args.tool_revision,
        dry_run=args.dry_run,
        stop_on_first_error=args.stop_on_first_error,
    )
    for r in results:
        print(json.dumps({
            "canonical_address": r.canonical_address,
            "status": r.status,
            "iu_id": str(r.iu_id) if r.iu_id else None,
            "log_id": str(r.log_id) if r.log_id else None,
            "from_status": r.from_status,
            "to_status": r.to_status,
            "transition_type": r.transition_type,
            "warnings": r.warnings,
        }))
    all_ok = all(r.status in ("enacted","already_enacted","plan_ok")
                 for r in results)
    return 0 if all_ok else 1

Exit codes (parity with CUT macro):

0 : all-pass (or dry-run plan_ok for every IU)
1 : at least one IU returned a non-success status
2 : preflight aborted (gateway not configured, vocab missing)
3 : connection / auth failure
4 : forbidden boundary breached
5 : reserved

14. G2 disposition

G2_DDL_function_authoring : PASS
production_mutation        : NONE
delivered:
  - Bundle A: iu_lifecycle_vocab + iu_lifecycle_log full DDL + seed + grants
  - Bundle B: fn_iu_enacted_immut + trg_iu_enacted_immut
              + fn_uv_enacted_immut + trg_uv_enacted_immut
              (full bodies, drop-if-exists pattern)
  - Bundle C: fn_iu_enact full SECDEF body (~230 lines, 14 steps, 9 return
              paths, 3 RAISE paths, 10 guards)
  - Bundle D: gateway UPDATE + 8 iu_enact.* dot_config keys + EXECUTE grants
  - Bundle F sketch: lifecycle_enact_adapter.py + cutprod_canonical.py enact
  - 8 negative-test probes (B-1..B-8)
  - 4 pre-flight probes
  - SQL syntax / static review checklist
  - defense-in-depth analysis (10 attack vectors covered)
next:
  - G3 — verification + rollback + compensation plan
    [[dot-iu-cutter-v0-5-04-verification-rollback-compensation-plan-2026-05-20]]
  - G2 patch package (Bundle E) in
    [[dot-iu-cutter-v0-5-03-fn-iu-apply-edit-draft-patch-package-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-03-fn-iu-apply-edit-draft-patch-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]]
Back to Knowledge Hub knowledge/dev/laws/dieu44-trien-khai/v0.5-lifecycle-enactment-implementation-authoring/dot-iu-cutter-v0.5-02-fn-iu-enact-ddl-package-2026-05-20.md