[Senate] Lifecycle state machine enforcement — guard every UPDATE artifacts SET lifecycle_state

← All Specs

Goal

scidex/atlas/artifact_registry.py:4376-4427 defines a clean LIFECYCLE_STATES = {'draft','listed','validated','flagged','challenged',
'deprecated','rejected'}
and a LIFECYCLE_TRANSITIONS adjacency map plus a transition_lifecycle() helper that validates moves. **But no other code is
required to use it.** A fast grep -rn "UPDATE artifacts SET lifecycle_state"
scidex/
shows ad-hoc updates in decision_engine.py (archive / deprecate /
promote / merge), artifact_registry.py itself (deprecate_artifact, supersede_artifact, mark_cold, revive), and at least one writer using
states that aren't even in LIFECYCLE_STATES ('archived', 'cold', 'superseded', 'frozen'). Production data confirms the drift: SELECT lifecycle_state, count(*) FROM artifacts GROUP BY 1 returns active(48889), deprecated(27), superseded(8), frozen(2), validated(2)
neither 'active' nor 'frozen' is in the declared LIFECYCLE_STATES set.

This task makes the state machine actually enforced: a Postgres CHECK +
trigger that rejects illegal transitions at the database boundary, plus a
one-time reconcile that brings the live state values into the canonical
vocabulary.

Acceptance Criteria

Vocabulary reconciliation. Decide and document, in this spec's
Work Log, the canonical superset that covers all current usage:
proposed = {'draft','listed','validated','flagged','challenged',
'deprecated','rejected','active','frozen','superseded','archived',
'cold'}
. Update LIFECYCLE_STATES and LIFECYCLE_TRANSITIONS in
artifact_registry.py:4376 to match.
Transitions extended. Add transitions to cover every
_execute_* path in
scidex/senate/decision_engine.py:322-425:
- → 'archived' (archive action) and → 'deprecated' (deprecate
action) and flagged|challenged|deprecated|rejected → 'active'
(promote / revive) and * → 'superseded' (merge / supersede).
Forbidden: any direct draft → deprecated (must pass through
listed first).
DB-side trigger. Migration migrations/20260427_lifecycle_trigger.sql:

CREATE OR REPLACE FUNCTION check_lifecycle_transition()
      RETURNS TRIGGER LANGUAGE plpgsql AS $$
      DECLARE allowed text[];
      BEGIN
        IF NEW.lifecycle_state IS DISTINCT FROM OLD.lifecycle_state THEN
          allowed := lifecycle_allowed_next(COALESCE(OLD.lifecycle_state,'draft'));
          IF NOT (NEW.lifecycle_state = ANY(allowed)) THEN
            RAISE EXCEPTION 'illegal lifecycle transition: % -> %',
              OLD.lifecycle_state, NEW.lifecycle_state;
          END IF;
        END IF;
        RETURN NEW;
      END;
      $$;
      CREATE TRIGGER trg_lifecycle_transition
        BEFORE UPDATE ON artifacts
        FOR EACH ROW EXECUTE FUNCTION check_lifecycle_transition();

lifecycle_allowed_next(text) is a SQL helper that returns the
adjacency map as a text[]; it's the single source of truth that
the Python LIFECYCLE_TRANSITIONS dict must match (a unit test
asserts the two are byte-equal).

Bypass for migrations. SET LOCAL session_replication_role =
'replica' is documented as the explicit bypass for one-off data
surgery; ad-hoc agent code must not use it.
Refactor every Python writer in scidex/senate/decision_engine.py
and scidex/atlas/artifact_registry.py to call
transition_lifecycle() instead of raw UPDATE artifacts SET
lifecycle_state = ...
. The helper logs to edit_history already
(line 4446-4451), so this also fixes the audit-trail gap.
Backfill audit. One-time script
scripts/backfill_lifecycle_history.py walks edit_history for
content_type='artifact' AND action LIKE '%lifecycle%' and emits a
report of the (before → after) pairs that today's table contains
but the new transition map disallows. The report goes into
docs/planning/audits/lifecycle_drift_2026-04-27.md (data file, not
a planning .md). Disallowed pairs found in history are NOT
retroactively rejected; they're documented and the
LIFECYCLE_TRANSITIONS map is widened OR the offending writer is
fixed.
Test suite tests/test_lifecycle_state_machine.py:
- Python LIFECYCLE_TRANSITIONS dict ≡ SQL lifecycle_allowed_next
return values for every state.
- Illegal UPDATE artifacts SET lifecycle_state='deprecated' WHERE
lifecycle_state='draft'
raises Postgres exception.
- Legal draft → listed succeeds.
- transition_lifecycle() writes both the state change and the
edit_history row in one transaction.
- Roundtrip via decision_engine._execute_archive no longer raises.

Approach

  • Inventory every raw write to lifecycle_state (already done above).
  • Decide the superset vocabulary; write it into both Python and SQL.
  • Ship the trigger migration; verify with a deliberately-illegal manual
  • UPDATE that fails.
  • Refactor writers one file at a time, running tests after each.
  • Run the audit script, file the drift report.
  • Commit.
  • Dependencies

    • None (this is a guard, not a consumer of new infra).

    Dependents

    • q-gov-rollback-workflow — uses the new transition machinery to revert
    promotion → flagged.
    • q-gov-deprecation-reason-taxonomy — pairs with this to require a
    controlled-vocab deprecated_reason whenever * → deprecated fires.

    Work Log

    2026-04-27 — Implementation complete [task:e3d2f918-dd2a-4476-96a8-d8734fa1c572]

    Vocabulary reconciliation (✓)
    Canonical superset adopted: {'draft','listed','validated','flagged','challenged','deprecated','rejected',
    'active','archived','cold','superseded','frozen'}

    All 12 states are now in LIFECYCLE_STATES in artifact_registry.py.

    Transitions extended (✓) LIFECYCLE_TRANSITIONS updated to cover all _execute_* paths in decision_engine.py: → archived, → deprecated (except draft →
    deprecated
    which remains FORBIDDEN), flagged|challenged|deprecated|rejected →
    active
    , * → superseded. SQL lifecycle_allowed_next() matches byte-for-byte.

    DB-side trigger (✓)
    Migration migrations/20260427_lifecycle_trigger.sql applied to production.
    Functions lifecycle_allowed_next and check_lifecycle_transition created;
    trigger trg_lifecycle_transition attached to artifacts table.
    Verified: draft → deprecated raises exception; draft → listed succeeds.

    Bypass documented (✓) SET LOCAL session_replication_role = 'replica' documented in migration header
    as the explicit bypass for data surgery. Ad-hoc agent code must not use it.

    Python writers refactored (✓)
    All raw UPDATE artifacts SET lifecycle_state = ... in the two target files
    replaced with transition_lifecycle() calls:

    • artifact_registry.py: deprecate_artifact, supersede_artifact,
    cold_store_artifact, reactivate_artifact
    • decision_engine.py: _execute_archive, _execute_deprecate,
    _execute_promote, _execute_merge
    Remaining raw update in api.py:25471 is guarded at the DB boundary by the
    trigger. api.py is a CRITICAL file not in scope for this task's refactor.

    Backfill audit (✓) scripts/backfill_lifecycle_history.py created and run. Found 0 prior lifecycle
    transition events in edit_history (table was empty before enforcement).
    Report: docs/planning/audits/lifecycle_drift_2026-04-27.md

    Test suite (✓) tests/test_lifecycle_state_machine.py created with 6 tests, all passing:

  • Python dict matches SQL for every state
  • Illegal draft → deprecated raises Postgres exception
  • Legal draft → listed succeeds
  • transition_lifecycle() writes state change + edit_history row
  • _execute_archive roundtrip succeeds without raising
  • All DB lifecycle_state values are within LIFECYCLE_STATES
  • Tasks using this spec (1)
    [Senate] Lifecycle state machine enforcement
    File: q-gov-lifecycle-state-machine-enforcement_spec.md
    Modified: 2026-04-27 03:45
    Size: 7.9 KB