Files
minerops/backend/alembic/versions/0013_control_board_replacement.py
T

437 lines
27 KiB
Python

"""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'")