"""add control board replacement workflow Revision ID: 0013_control_board_replacement Revises: 0012_return_to_service Create Date: 2026-08-02 """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql revision: str = "0013_control_board_replacement" down_revision: Union[str, None] = "0012_return_to_service" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None REPAIR_REASONS = ( "HARDWARE_FAILURE", "LOW_HASHRATE", "CONTROL_BOARD", "HASHBOARD", "PSU", "FAN", "OVERHEATING", "NETWORKING", "PREVENTIVE_MAINTENANCE", "INSPECTION", "OTHER", ) REPLACEMENT_REASONS = ( "failed", "upgrade", "diagnostic_swap", "preventive_maintenance", "temporary_donor", "unknown", ) DISPOSITIONS = ( "repair_inventory", "sent_for_repair", "scrapped", "retained_for_diagnosis", "returned_to_vendor", "unknown", ) def _quoted(values: Sequence[str]) -> str: return ", ".join(f"'{value}'" for value in values) def _create_event_constraints(include_replacement: bool) -> None: event_types = ["REPAIR_STARTED", "INITIAL_NOTE", "REPAIR_COMPLETED"] if include_replacement: event_types.append("CONTROL_BOARD_REPLACED") op.create_check_constraint( "ck_repair_events_type", "repair_events", f"event_type IN ({_quoted(event_types)})", ) replacement_clause = "" if include_replacement: replacement_clause = ( ") OR (event_type = 'CONTROL_BOARD_REPLACED' " "AND payload ?& ARRAY['old_mac_address', 'new_mac_address', " "'old_control_board_serial', 'new_control_board_serial', " "'replacement_reason', 'removed_board_disposition'] " "AND jsonb_typeof(payload -> 'old_mac_address') IN ('string', 'null') " "AND jsonb_typeof(payload -> 'new_mac_address') = 'string' " "AND payload ->> 'new_mac_address' ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' " "AND regexp_replace(lower(payload ->> 'new_mac_address'), '[^0-9a-f]', '', 'g') " "NOT IN ('000000000000', 'ffffffffffff') " "AND get_byte(decode(regexp_replace(lower(payload ->> 'new_mac_address'), " "'[^0-9a-f]', '', 'g'), 'hex'), 0) % 2 = 0 " "AND (jsonb_typeof(payload -> 'old_control_board_serial') = 'null' OR (" "jsonb_typeof(payload -> 'old_control_board_serial') = 'string' " "AND trim(payload ->> 'old_control_board_serial') <> '' " "AND char_length(payload ->> 'old_control_board_serial') <= 255)) " "AND (jsonb_typeof(payload -> 'new_control_board_serial') = 'null' OR (" "jsonb_typeof(payload -> 'new_control_board_serial') = 'string' " "AND trim(payload ->> 'new_control_board_serial') <> '' " "AND char_length(payload ->> 'new_control_board_serial') <= 255)) " f"AND payload ->> 'replacement_reason' IN ({_quoted(REPLACEMENT_REASONS)}) " f"AND payload ->> 'removed_board_disposition' IN ({_quoted(DISPOSITIONS)}) " "AND (NOT payload ? 'notes' OR jsonb_typeof(payload -> 'notes') = 'null' OR (" "jsonb_typeof(payload -> 'notes') = 'string' " "AND trim(payload ->> 'notes') <> '' " "AND char_length(payload ->> 'notes') <= 4000))" ) op.create_check_constraint( "ck_repair_events_payload_shape", "repair_events", "(event_type = 'REPAIR_STARTED' " "AND payload ?& ARRAY['reason', 'from_lifecycle', 'to_lifecycle'] " f"AND payload ->> 'reason' IN ({_quoted(REPAIR_REASONS)}) " "AND payload ->> 'from_lifecycle' IN ('active', 'spare') " "AND payload ->> 'to_lifecycle' = 'in_repair') OR (" "event_type = 'INITIAL_NOTE' " "AND jsonb_typeof(payload -> 'note') = 'string' " "AND trim(payload ->> 'note') <> '') OR (" "event_type = 'REPAIR_COMPLETED' " "AND payload ?& ARRAY['previous_miner_lifecycle', 'resulting_miner_lifecycle'] " "AND payload ->> 'previous_miner_lifecycle' = 'in_repair' " "AND payload ->> 'resulting_miner_lifecycle' IN ('active', 'spare') " "AND (NOT payload ? 'pre_repair_lifecycle' " "OR payload ->> 'pre_repair_lifecycle' IN ('active', 'spare')) " "AND (NOT payload ? 'completion_summary' OR (" "jsonb_typeof(payload -> 'completion_summary') = 'string' " "AND trim(payload ->> 'completion_summary') <> '' " "AND char_length(payload ->> 'completion_summary') <= 4000))" f"{replacement_clause})", ) def upgrade() -> None: connection = op.get_bind() connection.exec_driver_sql( """ DO $$ DECLARE conflicts TEXT; BEGIN WITH identities AS ( SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac, miner_id FROM miner_network_identities WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12 UNION ALL SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id FROM miners WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12 ), duplicate_owners AS ( SELECT mac FROM identities GROUP BY mac HAVING count(DISTINCT miner_id) > 1 ) SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts FROM duplicate_owners; IF conflicts IS NOT NULL THEN RAISE EXCEPTION USING MESSAGE = 'Cannot upgrade Control Board Replacement: normalized MAC values have multiple miner owners: ' || conflicts, HINT = 'Correct the conflicting miner and network-identity records before upgrading; MinerOps will not transfer, merge, or delete MAC history automatically.'; END IF; SELECT string_agg(mac, ', ' ORDER BY mac) INTO conflicts FROM ( SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac FROM miner_network_identities WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12 GROUP BY regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') HAVING count(*) > 1 ) AS duplicate_identity_mac; IF conflicts IS NOT NULL THEN RAISE EXCEPTION USING MESSAGE = 'Cannot upgrade Control Board Replacement: duplicate normalized network-identity MAC values: ' || conflicts, HINT = 'Correct duplicate identity rows before upgrading; MinerOps will not delete or merge history automatically.'; END IF; SELECT string_agg(miner_id::text, ', ' ORDER BY miner_id::text) INTO conflicts FROM ( SELECT miner_id FROM miner_network_identities WHERE is_current GROUP BY miner_id HAVING count(*) > 1 ) AS duplicate_current; IF conflicts IS NOT NULL THEN RAISE EXCEPTION USING MESSAGE = 'Cannot upgrade Control Board Replacement: miners have multiple current network identities: ' || conflicts, HINT = 'Choose one current network identity for each listed miner before upgrading.'; END IF; END $$; """ ) connection.exec_driver_sql( """ INSERT INTO permissions (key, description) VALUES ('miners.replace_control_board', 'Replace miner control boards during repair') ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description; INSERT INTO role_permissions (role_id, permission_id) SELECT roles.id, permissions.id FROM roles JOIN permissions ON permissions.key = 'miners.replace_control_board' WHERE roles.name IN ('Admin', 'Manager', 'Technician') ON CONFLICT DO NOTHING; """ ) op.add_column( "miner_network_identities", sa.Column("associated_at", sa.DateTime(timezone=True), nullable=True), ) op.alter_column("miner_network_identities", "first_seen_at", nullable=True) op.alter_column("miner_network_identities", "last_seen_at", nullable=True) connection.exec_driver_sql( """ CREATE UNIQUE INDEX uq_miner_network_identities_normalized_mac ON miner_network_identities ( regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') ) WHERE length(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g')) = 12; CREATE UNIQUE INDEX uq_miner_network_identities_one_current_per_miner ON miner_network_identities (miner_id) WHERE is_current; CREATE UNIQUE INDEX uq_miners_current_normalized_mac ON miners (regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) WHERE length(regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')) = 12; ALTER TABLE miner_network_identities ADD CONSTRAINT ck_miner_network_identities_mac_valid CHECK ( source NOT IN ('control_board_replacement', 'legacy_projection') OR ( mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') NOT IN ('000000000000', 'ffffffffffff') AND get_byte(decode(regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g'), 'hex'), 0) %% 2 = 0 ) ) NOT VALID; """ ) op.create_table( "miner_mac_ownerships", sa.Column("normalized_mac", sa.Text(), primary_key=True), sa.Column( "miner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False, ), sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True), sa.Column( "claimed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True, ), sa.CheckConstraint( "normalized_mac ~ '^[0-9a-f]{12}$' " "AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') " "AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0", name="ck_miner_mac_ownerships_valid", ), ) op.create_index("idx_miner_mac_ownerships_miner_id", "miner_mac_ownerships", ["miner_id"]) connection.exec_driver_sql( """ INSERT INTO miner_mac_ownerships (normalized_mac, miner_id) SELECT mac, min(miner_id::text)::uuid FROM ( SELECT regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') AS mac, miner_id FROM miner_network_identities UNION ALL SELECT regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g'), id FROM miners ) AS known WHERE length(mac) = 12 AND mac NOT IN ('000000000000', 'ffffffffffff') AND get_byte(decode(mac, 'hex'), 0) %% 2 = 0 GROUP BY mac; """ ) op.create_table( "miner_control_board_installations", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), sa.Column("miner_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("miners.id", ondelete="RESTRICT"), nullable=False), sa.Column("record_origin", sa.Text(), nullable=False), sa.Column("installed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True), sa.Column("removed_repair_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("repairs.id", ondelete="RESTRICT"), nullable=True), sa.Column("mac_address", sa.Text(), nullable=True), sa.Column("normalized_mac", sa.Text(), nullable=True), sa.Column("board_serial", sa.Text(), nullable=True), sa.Column("board_model", sa.Text(), nullable=True), sa.Column("board_revision", sa.Text(), nullable=True), sa.Column("firmware_version", sa.Text(), nullable=True), sa.Column("replacement_reason", sa.Text(), nullable=True), sa.Column("installed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("installed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True), sa.Column("removed_at", sa.DateTime(timezone=True), nullable=True), sa.Column("removed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=True), sa.Column("removed_disposition", sa.Text(), nullable=True), sa.Column("notes", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), sa.CheckConstraint("(mac_address IS NULL) = (normalized_mac IS NULL)", name="ck_control_board_installations_mac_pair"), sa.CheckConstraint( "normalized_mac IS NULL OR (" "mac_address ~ '^([0-9A-F]{2}:){5}[0-9A-F]{2}$' " "AND regexp_replace(lower(mac_address), '[^0-9a-f]', '', 'g') = normalized_mac " "AND normalized_mac ~ '^[0-9a-f]{12}$' " "AND normalized_mac NOT IN ('000000000000', 'ffffffffffff') " "AND get_byte(decode(normalized_mac, 'hex'), 0) % 2 = 0)", name="ck_control_board_installations_mac_normalized", ), sa.CheckConstraint("board_serial IS NULL OR (trim(board_serial) <> '' AND char_length(board_serial) <= 255)", name="ck_control_board_installations_serial"), sa.CheckConstraint("board_model IS NULL OR (trim(board_model) <> '' AND char_length(board_model) <= 255)", name="ck_control_board_installations_model"), sa.CheckConstraint("board_revision IS NULL OR (trim(board_revision) <> '' AND char_length(board_revision) <= 255)", name="ck_control_board_installations_revision"), sa.CheckConstraint("firmware_version IS NULL OR (trim(firmware_version) <> '' AND char_length(firmware_version) <= 255)", name="ck_control_board_installations_firmware"), sa.CheckConstraint(f"replacement_reason IS NULL OR replacement_reason IN ({_quoted(REPLACEMENT_REASONS)})", name="ck_control_board_installations_reason"), sa.CheckConstraint(f"removed_disposition IS NULL OR removed_disposition IN ({_quoted(DISPOSITIONS)})", name="ck_control_board_installations_disposition"), sa.CheckConstraint("notes IS NULL OR (trim(notes) <> '' AND char_length(notes) <= 4000)", name="ck_control_board_installations_notes"), sa.CheckConstraint("(removed_at IS NULL AND removed_by IS NULL AND removed_repair_id IS NULL AND removed_disposition IS NULL) OR (removed_at IS NOT NULL AND removed_by IS NOT NULL AND removed_repair_id IS NOT NULL AND removed_disposition IS NOT NULL)", name="ck_control_board_installations_removed_state"), sa.CheckConstraint("installed_at IS NULL OR removed_at IS NULL OR removed_at >= installed_at", name="ck_control_board_installations_chronology"), sa.CheckConstraint( "(record_origin = 'legacy_projection' AND installed_repair_id IS NULL " "AND installed_at IS NULL AND installed_by IS NULL " "AND replacement_reason IS NULL) OR " "(record_origin = 'replacement' AND installed_repair_id IS NOT NULL " "AND installed_at IS NOT NULL AND installed_by IS NOT NULL " "AND replacement_reason IS NOT NULL)", name="ck_control_board_installations_install_state", ), ) op.create_index("uq_control_board_installations_current_miner", "miner_control_board_installations", ["miner_id"], unique=True, postgresql_where=sa.text("removed_at IS NULL")) op.create_index("uq_control_board_installations_current_mac", "miner_control_board_installations", ["normalized_mac"], unique=True, postgresql_where=sa.text("removed_at IS NULL AND normalized_mac IS NOT NULL")) op.create_index("idx_control_board_installations_miner_history", "miner_control_board_installations", ["miner_id", "installed_at", "created_at", "id"]) op.create_index("idx_control_board_installations_mac_history", "miner_control_board_installations", ["normalized_mac", "created_at", "id"]) op.create_index("idx_control_board_installations_installed_repair", "miner_control_board_installations", ["installed_repair_id"]) op.create_index("idx_control_board_installations_removed_repair", "miner_control_board_installations", ["removed_repair_id"]) connection.exec_driver_sql( """ CREATE FUNCTION minerops_claim_mac_owner() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE mac text; existing_owner uuid; target_owner uuid; row_data jsonb; BEGIN row_data := to_jsonb(NEW); mac := regexp_replace(lower(CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'current_mac' ELSE row_data ->> 'mac_address' END), '[^0-9a-f]', '', 'g'); target_owner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid; IF mac IS NULL OR length(mac) <> 12 OR mac IN ('000000000000', 'ffffffffffff') OR get_byte(decode(mac, 'hex'), 0) %% 2 <> 0 THEN RETURN NEW; END IF; INSERT INTO miner_mac_ownerships (normalized_mac, miner_id, claimed_at) VALUES (mac, target_owner, now()) ON CONFLICT (normalized_mac) DO NOTHING; SELECT miner_id INTO existing_owner FROM miner_mac_ownerships WHERE normalized_mac = mac FOR UPDATE; IF existing_owner <> (target_owner) THEN RAISE EXCEPTION 'MAC %% is historically owned by miner %%', mac, existing_owner USING ERRCODE = '23505', CONSTRAINT = 'uq_miner_mac_ownerships_owner'; END IF; RETURN NEW; END $$; CREATE TRIGGER trg_miners_claim_mac_owner AFTER INSERT OR UPDATE OF current_mac ON miners FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner(); CREATE TRIGGER trg_network_identities_claim_mac_owner BEFORE INSERT OR UPDATE OF mac_address, miner_id ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_claim_mac_owner(); CREATE FUNCTION minerops_protect_mac_ownership() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Historical MAC ownership is immutable; use an explicit transfer workflow'; END $$; CREATE TRIGGER trg_protect_mac_ownership BEFORE UPDATE OR DELETE ON miner_mac_ownerships FOR EACH ROW EXECUTE FUNCTION minerops_protect_mac_ownership(); CREATE FUNCTION minerops_protect_network_identity_history() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Network identity history is immutable'; END IF; IF NEW.miner_id <> OLD.miner_id OR regexp_replace(lower(NEW.mac_address), '[^0-9a-f]', '', 'g') <> regexp_replace(lower(OLD.mac_address), '[^0-9a-f]', '', 'g') THEN RAISE EXCEPTION 'Network identity owner and normalized MAC are immutable'; END IF; RETURN NEW; END $$; CREATE TRIGGER trg_protect_network_identity_history BEFORE UPDATE OF miner_id, mac_address OR DELETE ON miner_network_identities FOR EACH ROW EXECUTE FUNCTION minerops_protect_network_identity_history(); CREATE FUNCTION minerops_check_current_board_projection() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE target_miner uuid; mismatch boolean; row_data jsonb; BEGIN IF TG_OP = 'DELETE' THEN row_data := to_jsonb(OLD); ELSE row_data := to_jsonb(NEW); END IF; target_miner := (CASE WHEN TG_TABLE_NAME = 'miners' THEN row_data ->> 'id' ELSE row_data ->> 'miner_id' END)::uuid; SELECT EXISTS ( SELECT 1 FROM miner_control_board_installations WHERE miner_id = target_miner ) AND NOT EXISTS ( SELECT 1 FROM miner_control_board_installations board JOIN miners miner ON miner.id = board.miner_id JOIN miner_network_identities identity ON identity.miner_id = board.miner_id AND identity.is_current WHERE board.miner_id = target_miner AND board.removed_at IS NULL AND regexp_replace(lower(COALESCE(miner.current_mac, '')), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '') AND miner.control_board_sn IS NOT DISTINCT FROM board.board_serial AND regexp_replace(lower(identity.mac_address), '[^0-9a-f]', '', 'g') = COALESCE(board.normalized_mac, '') ) INTO mismatch; IF mismatch THEN RAISE EXCEPTION 'Current control-board projection does not match installation history for miner %%', target_miner USING ERRCODE = '23514', CONSTRAINT = 'ck_current_control_board_projection'; END IF; RETURN NULL; END $$; CREATE CONSTRAINT TRIGGER trg_miners_check_current_board_projection AFTER INSERT OR UPDATE ON miners DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection(); CREATE CONSTRAINT TRIGGER trg_installations_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_control_board_installations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection(); CREATE CONSTRAINT TRIGGER trg_identities_check_current_board_projection AFTER INSERT OR UPDATE OR DELETE ON miner_network_identities DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION minerops_check_current_board_projection(); CREATE FUNCTION minerops_protect_control_board_history() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'Control-board installation history is immutable'; END IF; IF OLD.removed_at IS NOT NULL THEN RAISE EXCEPTION 'Removed control-board installation history is immutable'; END IF; IF NEW.id <> OLD.id OR NEW.miner_id <> OLD.miner_id OR NEW.record_origin <> OLD.record_origin OR NEW.installed_repair_id IS DISTINCT FROM OLD.installed_repair_id OR NEW.mac_address IS DISTINCT FROM OLD.mac_address OR NEW.normalized_mac IS DISTINCT FROM OLD.normalized_mac OR NEW.board_serial IS DISTINCT FROM OLD.board_serial OR NEW.board_model IS DISTINCT FROM OLD.board_model OR NEW.board_revision IS DISTINCT FROM OLD.board_revision OR NEW.firmware_version IS DISTINCT FROM OLD.firmware_version OR NEW.replacement_reason IS DISTINCT FROM OLD.replacement_reason OR NEW.installed_at IS DISTINCT FROM OLD.installed_at OR NEW.installed_by IS DISTINCT FROM OLD.installed_by OR NEW.notes IS DISTINCT FROM OLD.notes OR NEW.created_at <> OLD.created_at THEN RAISE EXCEPTION 'Control-board installation identity is immutable'; END IF; RETURN NEW; END $$; CREATE TRIGGER trg_protect_control_board_history BEFORE UPDATE OR DELETE ON miner_control_board_installations FOR EACH ROW EXECUTE FUNCTION minerops_protect_control_board_history(); """ ) op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check") op.drop_constraint("ck_repair_events_type", "repair_events", type_="check") _create_event_constraints(True) def downgrade() -> None: connection = op.get_bind() connection.exec_driver_sql( """ DO $$ DECLARE history_count bigint; BEGIN SELECT count(*) INTO history_count FROM miner_control_board_installations; history_count := history_count + (SELECT count(*) FROM repair_events WHERE event_type = 'CONTROL_BOARD_REPLACED'); IF history_count > 0 THEN RAISE EXCEPTION USING MESSAGE = format('Cannot downgrade Control Board Replacement: %s authoritative installation records or events would be destroyed', history_count), HINT = 'Preserve or explicitly migrate control-board history before downgrading; MinerOps will not discard or invent component, MAC, actor, or timestamp history.'; END IF; IF EXISTS (SELECT 1 FROM miner_network_identities WHERE first_seen_at IS NULL OR last_seen_at IS NULL) THEN RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: network identities contain unknown observation timestamps', HINT = 'Do not invent discovery timestamps; preserve or explicitly migrate these associations before downgrading.'; END IF; IF EXISTS (SELECT 1 FROM miner_network_identities WHERE associated_at IS NOT NULL) OR EXISTS (SELECT 1 FROM miner_mac_ownerships WHERE claimed_at IS NOT NULL) THEN RAISE EXCEPTION USING MESSAGE = 'Cannot downgrade Control Board Replacement: authoritative MAC association or ownership history would be destroyed', HINT = 'Preserve or explicitly migrate MAC association and ownership history before downgrading.'; END IF; END $$; """ ) op.drop_constraint("ck_repair_events_payload_shape", "repair_events", type_="check") op.drop_constraint("ck_repair_events_type", "repair_events", type_="check") _create_event_constraints(False) connection.exec_driver_sql( """ DROP TRIGGER trg_protect_control_board_history ON miner_control_board_installations; DROP FUNCTION minerops_protect_control_board_history(); DROP TRIGGER trg_installations_check_current_board_projection ON miner_control_board_installations; DROP TRIGGER trg_identities_check_current_board_projection ON miner_network_identities; DROP TRIGGER trg_miners_check_current_board_projection ON miners; DROP FUNCTION minerops_check_current_board_projection(); DROP TRIGGER trg_protect_mac_ownership ON miner_mac_ownerships; DROP FUNCTION minerops_protect_mac_ownership(); DROP TRIGGER trg_protect_network_identity_history ON miner_network_identities; DROP FUNCTION minerops_protect_network_identity_history(); DROP TRIGGER trg_network_identities_claim_mac_owner ON miner_network_identities; DROP TRIGGER trg_miners_claim_mac_owner ON miners; DROP FUNCTION minerops_claim_mac_owner(); """ ) op.drop_table("miner_control_board_installations") op.drop_index("idx_miner_mac_ownerships_miner_id", table_name="miner_mac_ownerships") op.drop_table("miner_mac_ownerships") op.drop_constraint("ck_miner_network_identities_mac_valid", "miner_network_identities", type_="check") op.drop_index("uq_miners_current_normalized_mac", table_name="miners") op.drop_index("uq_miner_network_identities_one_current_per_miner", table_name="miner_network_identities") op.drop_index("uq_miner_network_identities_normalized_mac", table_name="miner_network_identities") op.alter_column("miner_network_identities", "last_seen_at", nullable=False) op.alter_column("miner_network_identities", "first_seen_at", nullable=False) op.drop_column("miner_network_identities", "associated_at") connection.exec_driver_sql("DELETE FROM role_permissions WHERE permission_id = (SELECT id FROM permissions WHERE key = 'miners.replace_control_board')") connection.exec_driver_sql("DELETE FROM permissions WHERE key = 'miners.replace_control_board'")