scidex/atlas/artifact_registry.py:4376-4427 defines a clean
LIFECYCLE_STATES = {'draft','listed','validated','flagged','challenged', and a
'deprecated','rejected'}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" shows ad-hoc updates in
scidex/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.
{'draft','listed','validated','flagged','challenged',
'deprecated','rejected','active','frozen','superseded','archived',
'cold'}. Update LIFECYCLE_STATES and LIFECYCLE_TRANSITIONS inartifact_registry.py:4376 to match.
_execute_* path inscidex/senate/decision_engine.py:322-425: → 'archived' (archive action) and → 'deprecated' (deprecateflagged|challenged|deprecated|rejected → 'active'* → 'superseded' (merge / supersede).draft → deprecated (must pass throughlisted first).
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).
SET LOCAL session_replication_role =scidex/senate/decision_engine.pyscidex/atlas/artifact_registry.py to calltransition_lifecycle() instead of raw UPDATE artifacts SET
lifecycle_state = .... The helper logs to edit_history alreadyscripts/backfill_lifecycle_history.py walks edit_history forcontent_type='artifact' AND action LIKE '%lifecycle%' and emits a(before → after) pairs that today's table containsdocs/planning/audits/lifecycle_drift_2026-04-27.md (data file, notLIFECYCLE_TRANSITIONS map is widened OR the offending writer istests/test_lifecycle_state_machine.py:LIFECYCLE_TRANSITIONS dict ≡ SQL lifecycle_allowed_nextUPDATE artifacts SET lifecycle_state='deprecated' WHERE
lifecycle_state='draft' raises Postgres exception.draft → listed succeeds.transition_lifecycle() writes both the state change and thedecision_engine._execute_archive no longer raises.lifecycle_state (already done above).UPDATE that fails.
q-gov-rollback-workflow — uses the new transition machinery to revertq-gov-deprecation-reason-taxonomy — pairs with this to require adeprecated_reason whenever * → deprecated fires.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 → which remains FORBIDDEN),
deprecatedflagged|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_mergeapi.py:25471 is guarded at the DB boundary by theapi.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:
draft → deprecated raises Postgres exceptiondraft → listed succeedstransition_lifecycle() writes state change + edit_history row_execute_archive roundtrip succeeds without raisingLIFECYCLE_STATES