From 6ea16b35a25c9f1582fe0b2e3e8e7390b3c00e1c Mon Sep 17 00:00:00 2001 From: Abhinav Gupta Date: Mon, 20 Apr 2026 14:46:55 -0400 Subject: [PATCH] feat(server): add tenant scaffolding and targets schema Additive, behavior-preserving. tenant_id defaults to 'default-tenant' and is inert in this phase; new target tables exist but are not yet wired into runtime resolution or management APIs. --- ...2d30_add_tenant_scaffolding_and_targets.py | 164 +++++++++++++ server/src/agent_control_server/models.py | 119 +++++++++- server/tests/test_targets_models.py | 143 ++++++++++++ ...st_tenant_scaffolding_alembic_migration.py | 216 ++++++++++++++++++ 4 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 server/alembic/versions/7c1a9b4e2d30_add_tenant_scaffolding_and_targets.py create mode 100644 server/tests/test_targets_models.py create mode 100644 server/tests/test_tenant_scaffolding_alembic_migration.py diff --git a/server/alembic/versions/7c1a9b4e2d30_add_tenant_scaffolding_and_targets.py b/server/alembic/versions/7c1a9b4e2d30_add_tenant_scaffolding_and_targets.py new file mode 100644 index 00000000..d9e5ca52 --- /dev/null +++ b/server/alembic/versions/7c1a9b4e2d30_add_tenant_scaffolding_and_targets.py @@ -0,0 +1,164 @@ +"""Add tenant scaffolding and targets schema. + +Additive, behavior-preserving migration: + +- Adds opaque string ``tenant_id`` columns to ``agents``, ``controls``, + ``policies``, ``agent_controls``, and ``agent_policies``. Existing rows are + backfilled to ``default-tenant`` and columns are then made NOT NULL. A + DB-level ``server_default`` keeps writes that omit a tenant working. +- Creates new tables ``targets`` and ``target_controls``. Uniqueness on + ``targets`` covers ``(tenant_id, target_type, external_id)`` and on + ``target_controls`` covers ``(target_id, control_id)``. + ``target_controls.target_id`` uses ``ON DELETE CASCADE`` because the + attachment has no meaning without its target; ``control_id`` uses the + default restrictive behavior so control deletion does not silently cascade + into attachment cleanup. +- Intentionally omitted from this migration (to be addressed separately): + * ``policy_controls.tenant_id`` (tenant scope inherited transitively + through ``policy_id`` and ``control_id``). + * ``control_execution_events.tenant_id`` (observability tables out of + scope here). + * ``updated_at`` columns (no established auto-maintenance pattern in the + repo yet). + * Indexes on the new ``tenant_id`` columns (read paths do not filter on + tenant yet, so unused indexes would just add write cost). + +Revision ID: 7c1a9b4e2d30 +Revises: 5f2b5f4e1a90 +Create Date: 2026-04-20 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = "7c1a9b4e2d30" +down_revision = "5f2b5f4e1a90" +branch_labels = None +depends_on = None + + +DEFAULT_TENANT_ID = "default-tenant" + +_TENANT_SCOPED_TABLES = ( + "agents", + "controls", + "policies", + "agent_controls", + "agent_policies", +) + + +def upgrade() -> None: + # Step 1: add tenant_id as nullable on all affected tables. + for table in _TENANT_SCOPED_TABLES: + op.add_column( + table, + sa.Column("tenant_id", sa.String(length=64), nullable=True), + ) + + # Step 2: backfill existing rows to the synthetic default tenant. + for table in _TENANT_SCOPED_TABLES: + op.execute( + sa.text( + f"UPDATE {table} SET tenant_id = :tenant WHERE tenant_id IS NULL" + ).bindparams(tenant=DEFAULT_TENANT_ID) + ) + + # Step 3: make tenant_id NOT NULL and install the DB-level default so + # unscoped OSS writes continue to land in the default tenant automatically. + for table in _TENANT_SCOPED_TABLES: + op.alter_column( + table, + "tenant_id", + existing_type=sa.String(length=64), + nullable=False, + server_default=sa.text(f"'{DEFAULT_TENANT_ID}'"), + ) + + # Step 4: create the new target schema objects. + op.create_table( + "targets", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "tenant_id", + sa.String(length=64), + nullable=False, + server_default=sa.text(f"'{DEFAULT_TENANT_ID}'"), + ), + sa.Column("target_type", sa.String(length=64), nullable=False), + sa.Column("external_id", sa.String(length=255), nullable=False), + sa.Column("name", sa.String(length=255), nullable=True), + sa.Column( + "data", + sa.dialects.postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "tenant_id", + "target_type", + "external_id", + name="uq_targets_tenant_type_external_id", + ), + ) + + op.create_table( + "target_controls", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("target_id", sa.Integer(), nullable=False), + sa.Column("control_id", sa.Integer(), nullable=False), + sa.Column( + "enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("true"), + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["target_id"], ["targets.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["control_id"], ["controls.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "target_id", "control_id", name="uq_target_controls_target_control" + ), + ) + op.create_index( + op.f("ix_target_controls_target_id"), + "target_controls", + ["target_id"], + unique=False, + ) + op.create_index( + op.f("ix_target_controls_control_id"), + "target_controls", + ["control_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_target_controls_control_id"), table_name="target_controls" + ) + op.drop_index( + op.f("ix_target_controls_target_id"), table_name="target_controls" + ) + op.drop_table("target_controls") + op.drop_table("targets") + + for table in _TENANT_SCOPED_TABLES: + op.drop_column(table, "tenant_id") diff --git a/server/src/agent_control_server/models.py b/server/src/agent_control_server/models.py index 5da55730..9cabe1e0 100644 --- a/server/src/agent_control_server/models.py +++ b/server/src/agent_control_server/models.py @@ -6,6 +6,7 @@ from agent_control_models.server import EvaluatorSchema from pydantic import Field from sqlalchemy import ( + Boolean, CheckConstraint, Column, DateTime, @@ -14,6 +15,7 @@ Integer, String, Table, + UniqueConstraint, text, ) from sqlalchemy.dialects.postgresql import JSONB @@ -21,6 +23,11 @@ from .db import Base +# Synthetic tenant used when no explicit tenant is resolved for a request. +# In this initial rollout tenant_id is inert metadata on existing tables: +# writes stamp it via an ORM/DB default, but read paths do not filter on it. +DEFAULT_TENANT_ID = "default-tenant" + class AgentData(BaseModel): """Agent metadata stored in JSONB.""" @@ -30,7 +37,10 @@ class AgentData(BaseModel): evaluators: list[EvaluatorSchema] = Field(default_factory=list) -# Association table for Policy <> Control many-to-many relationship +# Association table for Policy <> Control many-to-many relationship. +# ``policy_controls`` deliberately does not carry tenant_id: tenant scope is +# inherited transitively through policy_id and control_id, both of which +# already point to tenant-owned rows. policy_controls: Table = Table( "policy_controls", Base.metadata, @@ -44,6 +54,13 @@ class AgentData(BaseModel): Base.metadata, Column("agent_name", ForeignKey("agents.name"), primary_key=True, index=True), Column("policy_id", ForeignKey("policies.id"), primary_key=True, index=True), + Column( + "tenant_id", + String(64), + nullable=False, + server_default=text(f"'{DEFAULT_TENANT_ID}'"), + default=DEFAULT_TENANT_ID, + ), ) # Association table for Agent <> Control many-to-many direct relationship @@ -52,6 +69,13 @@ class AgentData(BaseModel): Base.metadata, Column("agent_name", ForeignKey("agents.name"), primary_key=True, index=True), Column("control_id", ForeignKey("controls.id"), primary_key=True, index=True), + Column( + "tenant_id", + String(64), + nullable=False, + server_default=text(f"'{DEFAULT_TENANT_ID}'"), + default=DEFAULT_TENANT_ID, + ), ) @@ -60,6 +84,12 @@ class Policy(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + tenant_id: Mapped[str] = mapped_column( + String(64), + nullable=False, + server_default=text(f"'{DEFAULT_TENANT_ID}'"), + default=DEFAULT_TENANT_ID, + ) agents: Mapped[list["Agent"]] = relationship( "Agent", secondary=lambda: agent_policies, back_populates="policies" ) @@ -74,6 +104,12 @@ class Control(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + tenant_id: Mapped[str] = mapped_column( + String(64), + nullable=False, + server_default=text(f"'{DEFAULT_TENANT_ID}'"), + default=DEFAULT_TENANT_ID, + ) # JSONB payload describing control specifics data: Mapped[dict[str, Any]] = mapped_column( JSONB, server_default=text("'{}'::jsonb"), nullable=False @@ -96,6 +132,12 @@ class Agent(Base): ) name: Mapped[str] = mapped_column(String(255), primary_key=True) + tenant_id: Mapped[str] = mapped_column( + String(64), + nullable=False, + server_default=text(f"'{DEFAULT_TENANT_ID}'"), + default=DEFAULT_TENANT_ID, + ) data: Mapped[dict[str, Any]] = mapped_column( JSONB, server_default=text("'{}'::jsonb"), nullable=False ) @@ -114,6 +156,81 @@ def _normalize_name(self, _key: str, value: str) -> str: return normalize_agent_name(value) +# ============================================================================= +# Target Models +# ============================================================================= +# +# Targets are typed, tenant-scoped attachable objects. The schema is introduced +# here without being wired into runtime control resolution or management APIs; +# both are added in follow-up changes. ``target_controls`` inherits tenant +# scope transitively through ``target_id``. + + +class Target(Base): + """A typed, tenant-scoped attachable object (e.g. ``environment``). + + The column is named ``target_type`` rather than ``type`` to avoid + shadowing Python's builtin and to keep greps for the field specific. + """ + + __tablename__ = "targets" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "target_type", + "external_id", + name="uq_targets_tenant_type_external_id", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[str] = mapped_column( + String(64), + nullable=False, + server_default=text(f"'{DEFAULT_TENANT_ID}'"), + default=DEFAULT_TENANT_ID, + ) + target_type: Mapped[str] = mapped_column(String(64), nullable=False) + external_id: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str | None] = mapped_column(String(255), nullable=True) + data: Mapped[dict[str, Any]] = mapped_column( + JSONB, server_default=text("'{}'::jsonb"), nullable=False + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), + server_default=text("CURRENT_TIMESTAMP"), + nullable=False, + ) + + +class TargetControl(Base): + """Attachment of a control to a target with per-target enablement.""" + + __tablename__ = "target_controls" + __table_args__ = ( + UniqueConstraint("target_id", "control_id", name="uq_target_controls_target_control"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # CASCADE on target_id: a target_control row has no meaning without its target. + target_id: Mapped[int] = mapped_column( + Integer, ForeignKey("targets.id", ondelete="CASCADE"), nullable=False, index=True + ) + # RESTRICT (default) on control_id: do not silently fan control deletes into + # attachment cleanup; callers must remove attachments explicitly. + control_id: Mapped[int] = mapped_column( + Integer, ForeignKey("controls.id"), nullable=False, index=True + ) + enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("true"), default=True + ) + created_at: Mapped[dt.datetime] = mapped_column( + DateTime(timezone=True), + server_default=text("CURRENT_TIMESTAMP"), + nullable=False, + ) + + # ============================================================================= # Observability Models # ============================================================================= diff --git a/server/tests/test_targets_models.py b/server/tests/test_targets_models.py new file mode 100644 index 00000000..d9ca4b83 --- /dev/null +++ b/server/tests/test_targets_models.py @@ -0,0 +1,143 @@ +"""ORM round-trip coverage for the target schema. + +Also provides a behavior-preservation smoke assertion confirming that +existing writes (Agent/Control/Policy created without tenant context) still +land in the synthetic default tenant via the ORM-level default. +""" + +from __future__ import annotations + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from agent_control_server.models import ( + DEFAULT_TENANT_ID, + Agent, + Control, + Policy, + Target, + TargetControl, +) + + +def _unique(prefix: str) -> str: + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +async def test_target_roundtrip_populates_defaults(async_db: AsyncSession) -> None: + target = Target( + target_type="environment", + external_id=_unique("ls"), + name="production", + ) + async_db.add(target) + await async_db.commit() + await async_db.refresh(target) + + fetched = ( + await async_db.execute(select(Target).where(Target.id == target.id)) + ).scalar_one() + + assert fetched.tenant_id == DEFAULT_TENANT_ID + assert fetched.target_type == "environment" + assert fetched.data == {} + assert fetched.created_at is not None + + +async def test_targets_unique_per_tenant_type_external_id( + async_db: AsyncSession, +) -> None: + external_id = _unique("ls") + async_db.add(Target(target_type="environment", external_id=external_id)) + await async_db.commit() + + async_db.add(Target(target_type="environment", external_id=external_id)) + with pytest.raises(IntegrityError): + await async_db.commit() + await async_db.rollback() + + +async def test_target_control_roundtrip_defaults_enabled_true( + async_db: AsyncSession, +) -> None: + target = Target(target_type="environment", external_id=_unique("ls")) + control = Control(name=_unique("control"), data={}) + async_db.add_all([target, control]) + await async_db.commit() + + attachment = TargetControl(target_id=target.id, control_id=control.id) + async_db.add(attachment) + await async_db.commit() + await async_db.refresh(attachment) + + assert attachment.enabled is True + assert attachment.created_at is not None + + +async def test_target_controls_unique_per_target_control( + async_db: AsyncSession, +) -> None: + target = Target(target_type="environment", external_id=_unique("ls")) + control = Control(name=_unique("control"), data={}) + async_db.add_all([target, control]) + await async_db.commit() + + async_db.add(TargetControl(target_id=target.id, control_id=control.id)) + await async_db.commit() + + async_db.add(TargetControl(target_id=target.id, control_id=control.id)) + with pytest.raises(IntegrityError): + await async_db.commit() + await async_db.rollback() + + +async def test_target_delete_cascades_target_controls( + async_db: AsyncSession, +) -> None: + target = Target(target_type="environment", external_id=_unique("ls")) + control = Control(name=_unique("control"), data={}) + async_db.add_all([target, control]) + await async_db.commit() + + async_db.add(TargetControl(target_id=target.id, control_id=control.id)) + await async_db.commit() + + await async_db.delete(target) + await async_db.commit() + + remaining = ( + await async_db.execute(select(TargetControl).where(TargetControl.control_id == control.id)) + ).scalars().all() + assert remaining == [] + + +async def test_oss_agent_write_gets_default_tenant_without_explicit_input( + async_db: AsyncSession, +) -> None: + """Behavior-preservation smoke: existing OSS write path must not require tenant.""" + name = "oss-legacy-agent-01" + async_db.add(Agent(name=name, data={})) + await async_db.commit() + + agent = ( + await async_db.execute(select(Agent).where(Agent.name == name)) + ).scalar_one() + assert agent.tenant_id == DEFAULT_TENANT_ID + + +async def test_oss_control_and_policy_writes_get_default_tenant( + async_db: AsyncSession, +) -> None: + control = Control(name=_unique("ctrl"), data={}) + policy = Policy(name=_unique("pol")) + async_db.add_all([control, policy]) + await async_db.commit() + await async_db.refresh(control) + await async_db.refresh(policy) + + assert control.tenant_id == DEFAULT_TENANT_ID + assert policy.tenant_id == DEFAULT_TENANT_ID diff --git a/server/tests/test_tenant_scaffolding_alembic_migration.py b/server/tests/test_tenant_scaffolding_alembic_migration.py new file mode 100644 index 00000000..15dfc080 --- /dev/null +++ b/server/tests/test_tenant_scaffolding_alembic_migration.py @@ -0,0 +1,216 @@ +"""Alembic coverage for the tenant scaffolding and targets schema migration.""" + +from __future__ import annotations + +import json +import uuid +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, text +from sqlalchemy.engine import Engine, make_url + +from agent_control_server.config import db_config +from agent_control_server.models import DEFAULT_TENANT_ID + +SERVER_DIR = Path(__file__).resolve().parents[1] +PRE_MIGRATION_REVISION = "5f2b5f4e1a90" +MIGRATION_REVISION = "7c1a9b4e2d30" +_BASE_DB_URL = make_url(db_config.get_url()) + +pytestmark = pytest.mark.skipif( + _BASE_DB_URL.get_backend_name() != "postgresql", + reason="Tenant scaffolding Alembic migration tests require PostgreSQL.", +) + +_AGENT_NAME = "legacy-agent-01" +_CONTROL_NAME = "legacy-control" +_POLICY_NAME = "legacy-policy" + + +@pytest.fixture +def temp_db_url() -> str: + temp_db_name = f"agent_control_tenant_{uuid.uuid4().hex[:12]}" + admin_url = _BASE_DB_URL.set(database="postgres").render_as_string(hide_password=False) + target_url = _BASE_DB_URL.set(database=temp_db_name).render_as_string(hide_password=False) + + admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + with admin_engine.connect() as conn: + conn.execute(text(f'CREATE DATABASE "{temp_db_name}"')) + admin_engine.dispose() + + try: + yield target_url + finally: + cleanup_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT") + with cleanup_engine.connect() as conn: + conn.execute( + text( + """ + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = :db_name AND pid <> pg_backend_pid() + """ + ), + {"db_name": temp_db_name}, + ) + conn.execute(text(f'DROP DATABASE IF EXISTS "{temp_db_name}"')) + cleanup_engine.dispose() + + +@pytest.fixture +def alembic_config(temp_db_url: str) -> Config: + cfg = Config(str(SERVER_DIR / "alembic.ini")) + cfg.set_main_option("script_location", str(SERVER_DIR / "alembic")) + cfg.set_main_option("sqlalchemy.url", temp_db_url) + return cfg + + +@pytest.fixture +def temp_engine(temp_db_url: str) -> Engine: + engine = create_engine(temp_db_url, future=True) + try: + yield engine + finally: + engine.dispose() + + +def _seed_pre_migration_rows(engine: Engine) -> tuple[int, int]: + """Insert one row each into the tenant-bearing tables and return (control_id, policy_id).""" + with engine.begin() as conn: + control_id = int( + conn.execute( + text( + "INSERT INTO controls (name, data) " + "VALUES (:name, CAST(:data AS JSONB)) RETURNING id" + ), + {"name": _CONTROL_NAME, "data": json.dumps({})}, + ).scalar_one() + ) + policy_id = int( + conn.execute( + text("INSERT INTO policies (name) VALUES (:name) RETURNING id"), + {"name": _POLICY_NAME}, + ).scalar_one() + ) + conn.execute( + text( + "INSERT INTO agents (name, data) " + "VALUES (:name, CAST(:data AS JSONB))" + ), + {"name": _AGENT_NAME, "data": json.dumps({})}, + ) + conn.execute( + text( + "INSERT INTO agent_controls (agent_name, control_id) " + "VALUES (:agent, :control)" + ), + {"agent": _AGENT_NAME, "control": control_id}, + ) + conn.execute( + text( + "INSERT INTO agent_policies (agent_name, policy_id) " + "VALUES (:agent, :policy)" + ), + {"agent": _AGENT_NAME, "policy": policy_id}, + ) + return control_id, policy_id + + +def test_migration_backfills_existing_rows_to_default_tenant( + alembic_config: Config, + temp_engine: Engine, +) -> None: + """Pre-existing rows on all tenant-bearing tables must land in DEFAULT_TENANT_ID.""" + command.upgrade(alembic_config, PRE_MIGRATION_REVISION) + _seed_pre_migration_rows(temp_engine) + + command.upgrade(alembic_config, MIGRATION_REVISION) + + with temp_engine.begin() as conn: + for table in ("agents", "controls", "policies", "agent_controls", "agent_policies"): + rows = conn.execute(text(f"SELECT tenant_id FROM {table}")).all() + assert rows, f"expected at least one seeded row in {table}" + for (tenant_id,) in rows: + assert tenant_id == DEFAULT_TENANT_ID, ( + f"{table} row was not backfilled to the default tenant" + ) + + +def test_migration_creates_targets_and_target_controls( + alembic_config: Config, + temp_engine: Engine, +) -> None: + """New tables exist with the expected uniqueness constraints.""" + command.upgrade(alembic_config, MIGRATION_REVISION) + + with temp_engine.begin() as conn: + conn.execute( + text( + "INSERT INTO targets (tenant_id, target_type, external_id, name) " + "VALUES (:tenant, 'environment', 'ext-1', 'production')" + ), + {"tenant": DEFAULT_TENANT_ID}, + ) + # Duplicate on (tenant_id, target_type, external_id) must be rejected. + with pytest.raises(Exception): + conn.execute( + text( + "INSERT INTO targets (tenant_id, target_type, external_id, name) " + "VALUES (:tenant, 'environment', 'ext-1', 'dup')" + ), + {"tenant": DEFAULT_TENANT_ID}, + ) + + +def test_migration_server_default_preserves_oss_writes( + alembic_config: Config, + temp_engine: Engine, +) -> None: + """Inserting without tenant_id after migration still lands on DEFAULT_TENANT_ID.""" + command.upgrade(alembic_config, MIGRATION_REVISION) + + with temp_engine.begin() as conn: + conn.execute( + text( + "INSERT INTO controls (name, data) " + "VALUES (:name, CAST('{}' AS JSONB))" + ), + {"name": "post-migration-control"}, + ) + tenant_id = conn.execute( + text("SELECT tenant_id FROM controls WHERE name = :name"), + {"name": "post-migration-control"}, + ).scalar_one() + assert tenant_id == DEFAULT_TENANT_ID + + +def test_migration_downgrade_drops_tenant_and_target_objects( + alembic_config: Config, + temp_engine: Engine, +) -> None: + """Downgrade is complete: tenant_id columns are gone and new tables are dropped.""" + command.upgrade(alembic_config, MIGRATION_REVISION) + command.downgrade(alembic_config, PRE_MIGRATION_REVISION) + + with temp_engine.begin() as conn: + tables = conn.execute( + text( + "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = 'public'" + ) + ).scalars().all() + assert "targets" not in tables + assert "target_controls" not in tables + + for table in ("agents", "controls", "policies", "agent_controls", "agent_policies"): + columns = conn.execute( + text( + "SELECT column_name FROM information_schema.columns " + "WHERE table_schema = 'public' AND table_name = :t" + ), + {"t": table}, + ).scalars().all() + assert "tenant_id" not in columns, f"tenant_id leaked on {table} after downgrade"