"""Remove retired template scope metadata without changing execution scopes.""" import sqlalchemy as sa from alembic import op revision = "0023" down_revision = "0022" branch_labels = None depends_on = None # Only structured research documents carry reusable template definitions. Do not # rewrite opaque model messages, audit digests, or already-authorized tool calls. DOCUMENTS = ( ("research_revisions", ("asset_id", "version"), ("content", "provenance")), ("research_experiments", ("id",), ("evidence", "parents")), ("research_flow_runs", ("id",), ("definition", "authorization")), ("research_step_runs", ("id",), ("output",)), ("research_requests", ("id",), ("response",)), ) def remove_template_scope(value): """Copy structured templates without scope; preserve input scopes and bindings. Match the frozen persisted TemplateSpec shape, including nested feature templates and asset snapshots. Never remove an arbitrary key named scope: input snapshots and scope-named expression variables still require it. """ if isinstance(value, list): return [remove_template_scope(item) for item in value] if not isinstance(value, dict): return value is_template = ( isinstance(value.get("name"), str) and isinstance(value.get("expression"), str) and isinstance(value.get("variables"), dict) ) return { key: remove_template_scope(item) for key, item in value.items() if not (is_template and key == "scope") } def upgrade(): """Clean template documents in bounded batches, retaining IDs and versions.""" connection = op.get_bind() for name, keys, documents in DOCUMENTS: table = sa.table(name, *( [sa.column(key, sa.Integer() if key == "version" else sa.String()) for key in keys] + [sa.column(column, sa.JSON()) for column in documents] )) last = None while True: query = sa.select(table).order_by(*(table.c[key] for key in keys)).limit(500) if last is not None: query = query.where(sa.or_(*( sa.and_(*(table.c[keys[j]] == last[j] for j in range(i)), table.c[key] > last[i]) for i, key in enumerate(keys) ))) rows = connection.execute(query).mappings().all() if not rows: break for row in rows: changes = {} for column in documents: cleaned = remove_template_scope(row[column]) if cleaned != row[column]: changes[column] = cleaned if changes: connection.execute(table.update().where( *(table.c[key] == row[key] for key in keys) ).values(**changes)) last = tuple(rows[-1][key] for key in keys) def downgrade(): # Previous schemas allow absent template scope; deleted metadata cannot be # reconstructed. Execution scopes and all other research data are unchanged. pass