From 80034bbacf06797131ad87a9a4b78a372263dd55 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 19 Oct 2026 22:05:46 +0200 Subject: [PATCH] feat(tenants): add a default address on each consumer grant (T-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude.ai's connector keeps no MCP session between tool calls, so the Office cannot remember which address a conversation chose. Each consumer grant now carries a fallback: the Office uses it when a call names none and has no session binding (tatlock-office T-5, in a parallel round, reads it). Migration 008 (down_revision="007") adds tenant_consumers.default_address — nullable varchar(63) — and a composite foreign key (tenant_id, default_address) -> scribe_addresses(owner_tenant, name), so a default always names a real address in the same tenant. scribe_addresses already carries both a primary key and a named unique constraint on (owner_tenant, name) (migration 006), so the composite FK has something to bind to; Postgres's default MATCH SIMPLE means a NULL default_address never triggers the check, which is the state every grant but one is left in. The data step sets jpmschweitzer/office-claude -> claude-ai only if that address exists at upgrade time (true in production, false in core_api_test, where no scribe_addresses are seeded) — an UPDATE ... WHERE EXISTS, not a bare UPDATE, so it is a no-op rather than a failure when absent. llm_tester/office-claude is untouched and stays null. Downgrade drops the FK then the column. TenantConsumer (models.py) gains the column and a matching ForeignKeyConstraint. TenantsService.get_me now returns a third element, the caller's own consumer's default_address (looked up only when caller.consumer is not None — a user-kind caller carries no consumer, so there is no grant row to hold a default for it) or None. GET /tenants/me answers it; GET /tenants and its TenantSummary schema are unchanged. Tests: TestMigration008RoundTrip/ForeignKey/DataStep added to tests/test_tenants_migration.py (round-trip 007<->008, the FK existing and refusing an unknown address while allowing null, the data step's no-op and positive cases). tests/conftest.py gains a default_address_grant fixture (registers a throwaway address, points a grant at it, reverses both on teardown). tests/test_tenants.py gains TestMeDefaultAddress (an office caller with/without a default, a user caller always null). Friction found and fixed in the same commit: the new composite FK made Postgres refuse tests/test_scribe.py's existing clean_scribe_tables TRUNCATE ("cannot truncate a table referenced in a foreign key constraint") — the restriction is on the constraint's existence, not on whether any row's default_address is actually non-null. tenant_consumers joins that TRUNCATE; CASCADE was rejected because it would silently drop every grant rather than only addresses, so the four grants (jpmschweitzer/llm_tester x office-claude/office-gemini) are reinserted in the same fixture, right after. Mutation-checked: forcing default_address to null in the controller fails exactly test_office_caller_with_a_default_sees_it, leaving the without-a-default and user-caller tests green. Disabling the FK's creation (and, symmetrically, its drop in downgrade — to keep the round-trip self-consistent against the live core_api_test schema) fails test_fk_exists and test_fk_refuses_a_default_naming_an_address_that_does_not_exist, leaving test_fk_allows_a_null_default_regardless_of_tenant green. Both mutations reverted; the full suite re-verified green (543 + 11 = 554 passed). Co-Authored-By: Claude Sonnet 5 --- ...add_default_address_to_tenant_consumers.py | 77 +++++++ src/domains/tenants/controller.py | 3 +- src/domains/tenants/models.py | 31 ++- src/domains/tenants/schemas.py | 9 +- src/domains/tenants/service.py | 22 +- tests/conftest.py | 90 +++++++- tests/test_tenants.py | 29 +++ tests/test_tenants_migration.py | 196 +++++++++++++++++- 8 files changed, 444 insertions(+), 13 deletions(-) create mode 100644 alembic/versions/20261019_1700_008_add_default_address_to_tenant_consumers.py diff --git a/alembic/versions/20261019_1700_008_add_default_address_to_tenant_consumers.py b/alembic/versions/20261019_1700_008_add_default_address_to_tenant_consumers.py new file mode 100644 index 0000000..acb3a82 --- /dev/null +++ b/alembic/versions/20261019_1700_008_add_default_address_to_tenant_consumers.py @@ -0,0 +1,77 @@ +"""Add a default address to each consumer grant (core-api T-11) + +Revision ID: 008 +Revises: 007 +Create Date: 2026-10-19 + +claude.ai's connector keeps no MCP session between tool calls, so the +Office cannot remember which address a conversation chose. Each consumer +grant now carries `default_address`: the Office uses it when a call names +none and has no session binding (tatlock-office T-5 reads it). + +`tenant_consumers.default_address` is nullable, and a composite foreign +key `(tenant_id, default_address)` -> `scribe_addresses(owner_tenant, +name)` ensures a default always names a real address in the same tenant. +Postgres's default `MATCH SIMPLE` means a NULL `default_address` never +triggers the check, so an ungranted default is simply absent rather than +a constraint violation waiting to happen. + +Data step: `jpmschweitzer`/`office-claude` defaults to `claude-ai` only if +that address already exists at upgrade time - true in production, false +in `core_api_test` (no scribe_addresses are seeded there), so the `UPDATE +... WHERE EXISTS` must be - and is - a no-op rather than a failure when +the address is absent. `llm_tester`/`office-claude` is not touched and +stays null. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "008" +down_revision: Union[str, None] = "007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add the column, bind it to scribe_addresses, then seed the one + production default the dossier specifies.""" + + op.add_column( + "tenant_consumers", + sa.Column("default_address", sa.String(length=63), nullable=True), + ) + op.create_foreign_key( + "fk_tenant_consumers_default_address", + "tenant_consumers", + "scribe_addresses", + ["tenant_id", "default_address"], + ["owner_tenant", "name"], + ) + + # Only if claude-ai already exists in jpmschweitzer (production: yes; + # core_api_test: no) - an UPDATE ... WHERE EXISTS is a no-op rather + # than a failure when it does not. + op.execute( + """ + UPDATE tenant_consumers + SET default_address = 'claude-ai' + WHERE tenant_id = 'jpmschweitzer' + AND consumer = 'office-claude' + AND EXISTS ( + SELECT 1 FROM scribe_addresses + WHERE owner_tenant = 'jpmschweitzer' AND name = 'claude-ai' + ) + """ + ) + + +def downgrade() -> None: + """Unbind and drop the column.""" + op.drop_constraint( + "fk_tenant_consumers_default_address", "tenant_consumers", type_="foreignkey" + ) + op.drop_column("tenant_consumers", "default_address") diff --git a/src/domains/tenants/controller.py b/src/domains/tenants/controller.py index b7b1cff..53f7f9c 100644 --- a/src/domains/tenants/controller.py +++ b/src/domains/tenants/controller.py @@ -51,7 +51,7 @@ class TenantsController(BaseController): session: AsyncSession = Depends(get_async_session), ): try: - tenant, consumers = await service.get_me(session, caller) + tenant, consumers, default_address = await service.get_me(session, caller) except TenantError as error: raise HTTPException( status_code=error.status_code, detail={"code": error.code} @@ -62,6 +62,7 @@ class TenantsController(BaseController): kind=tenant.kind, status=tenant.status, consumers=consumers, + default_address=default_address, ) # ===================================================================== diff --git a/src/domains/tenants/models.py b/src/domains/tenants/models.py index de536cb..bc9ebb9 100644 --- a/src/domains/tenants/models.py +++ b/src/domains/tenants/models.py @@ -7,10 +7,25 @@ and `TenantsService.require_tenant` is the one place a caller's tenant is checked known, active, and — for an Office caller — granted to the calling consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following src/domains/book/models.py. + +`TenantConsumer.default_address` (migration 008, core-api T-11) is each +grant's fallback address for a caller that names none and holds no session +binding — claude.ai's connector keeps no MCP session between tool calls, so +the Office needs this to answer without one. The composite FK keeps it +honest: a default always names a real address in the same tenant. """ from datetime import datetime +from typing import Optional -from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Text, func +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + ForeignKeyConstraint, + String, + Text, + func, +) from sqlalchemy.orm import Mapped, mapped_column from src.shared.database import Base @@ -55,14 +70,26 @@ class Tenant(Base): class TenantConsumer(Base): - """A grant: consumer `consumer` may act in tenant `tenant_id`.""" + """A grant: consumer `consumer` may act in tenant `tenant_id`, with an + optional default address (migration 008, core-api T-11) — the composite + FK below means a non-null default always names a real + `scribe_addresses` row in this same tenant.""" __tablename__ = "tenant_consumers" + __table_args__ = ( + ForeignKeyConstraint( + ["tenant_id", "default_address"], + ["scribe_addresses.owner_tenant", "scribe_addresses.name"], + name="fk_tenant_consumers_default_address", + ), + ) tenant_id: Mapped[str] = mapped_column( String(63), ForeignKey("tenants.id"), primary_key=True ) consumer: Mapped[str] = mapped_column(String(63), primary_key=True) + # FK to scribe_addresses (owner_tenant, name) added by migration 008. + default_address: Mapped[Optional[str]] = mapped_column(String(63), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/src/domains/tenants/schemas.py b/src/domains/tenants/schemas.py index 43147d7..a7f0800 100644 --- a/src/domains/tenants/schemas.py +++ b/src/domains/tenants/schemas.py @@ -5,7 +5,7 @@ Pydantic request/response payloads for the tenant registry (core-api T-8 / workspace D-73). No write routes this round — provisioning is later (workspace T-195). """ -from typing import List +from typing import List, Optional from src.shared.base import BaseSchema @@ -21,9 +21,14 @@ class TenantSummary(BaseSchema): class TenantMeResponse(TenantSummary): """`GET /tenants/me` — the caller's own tenant plus the consumers - granted in it, the cheap "am I allowed here" probe (D-73).""" + granted in it, the cheap "am I allowed here" probe (D-73). + + `default_address` (core-api T-11) is the caller's own consumer's + default address, or null — null for a user-kind caller (no consumer to + look a default up for) and for an office caller whose grant has none.""" consumers: List[str] + default_address: Optional[str] = None class TenantListResponse(BaseSchema): diff --git a/src/domains/tenants/service.py b/src/domains/tenants/service.py index 74bf22b..ab4ab47 100644 --- a/src/domains/tenants/service.py +++ b/src/domains/tenants/service.py @@ -81,9 +81,16 @@ class TenantsService: return tenant - async def get_me(self, session: AsyncSession, caller: Caller) -> Tuple[Tenant, List[str]]: + async def get_me( + self, session: AsyncSession, caller: Caller + ) -> Tuple[Tenant, List[str], Optional[str]]: """`GET /tenants/me` — through the same helper, so "am I allowed - here" shares its answer with every other tenant-scoped call.""" + here" shares its answer with every other tenant-scoped call. + + The third element is the caller's own consumer's default address + (core-api T-11), or None — looked up only for an office caller + (`caller.consumer is not None`); a user-kind caller carries no + consumer, so there is no grant row to hold a default for it.""" tenant_id = await self.require_tenant(session, caller) tenant = await session.get(Tenant, tenant_id) @@ -93,7 +100,16 @@ class TenantsService: .order_by(TenantConsumer.consumer) ) consumers = list((await session.execute(consumers_stmt)).scalars().all()) - return tenant, consumers + + default_address: Optional[str] = None + if caller.consumer is not None: + default_address = await session.scalar( + select(TenantConsumer.default_address).where( + TenantConsumer.tenant_id == tenant_id, + TenantConsumer.consumer == caller.consumer, + ) + ) + return tenant, consumers, default_address async def list_tenants(self, session: AsyncSession, caller: Caller) -> List[Tenant]: """ diff --git a/tests/conftest.py b/tests/conftest.py index 44cf32b..d3209f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -225,13 +225,36 @@ async def scribe_session(scribe_schema: str): @pytest.fixture async def clean_scribe_tables(scribe_session): """Scribe service methods commit internally (same reason as Book's - clean_book_tables). TRUNCATE all three tables together so Postgres - resolves the FK order (mailbox_entries -> messages, messages' - self-referencing reply_to) across the one statement.""" + clean_book_tables). TRUNCATE all tables together so Postgres resolves + the FK order (mailbox_entries -> messages, messages' self-referencing + reply_to) across the one statement. + + `tenant_consumers` joined the list for migration 008 (core-api T-11): + its new composite FK `(tenant_id, default_address)` -> + `scribe_addresses(owner_tenant, name)` means Postgres now refuses to + truncate `scribe_addresses` alone — "cannot truncate a table + referenced in a foreign key constraint" — a restriction on the + constraint's existence, not on whether any row's `default_address` is + actually non-null (it never is here; migration 008's data step is a + no-op in `core_api_test`). `TRUNCATE ... CASCADE` was rejected: it + would silently drop every grant rather than only addresses, so the + four grants this file's tests depend on — both seeded tenants x + office-claude/office-gemini, the same pairing `_grant_test_office_gemini` + establishes once per session — are reinserted immediately after.""" from sqlalchemy import text await scribe_session.execute( - text("TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses") + text( + "TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses, " + "tenant_consumers" + ) + ) + await scribe_session.execute( + text( + "INSERT INTO tenant_consumers (tenant_id, consumer) VALUES " + "('jpmschweitzer', 'office-claude'), ('llm_tester', 'office-claude'), " + "('jpmschweitzer', 'office-gemini'), ('llm_tester', 'office-gemini')" + ) ) await scribe_session.commit() yield scribe_session @@ -287,3 +310,62 @@ async def suspended_tenant(test_database_url: str): await session.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id}) await session.commit() await engine.dispose() + + +@pytest.fixture +async def default_address_grant(tenant_registry: str, test_database_url: str): + """ + Give `jpmschweitzer`/`office-claude` a default address (core-api T-11): + registers a throwaway `scribe_addresses` row first — migration 008's + composite FK means the grant's default must name a real address in the + same tenant — then points the grant at it. Reversed on teardown (default + back to null, the address row deleted) so the rest of the suite sees the + null default that `core_api_test`'s own copy of migration 008 actually + leaves this grant with (see test_tenants_migration.py's + TestMigration008DataStep: claude-ai is never seeded here). + """ + from sqlalchemy import text + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + from sqlalchemy.pool import NullPool + + tenant_id = "jpmschweitzer" + consumer = "office-claude" + address = "test-default-address" + + asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + engine = create_async_engine(asyncpg_url, poolclass=NullPool) + session_factory = async_sessionmaker(bind=engine, expire_on_commit=False) + + async with session_factory() as session: + await session.execute( + text( + "INSERT INTO scribe_addresses (owner_tenant, name, created_by) " + "VALUES (:tenant, :address, 'test-harness')" + ), + {"tenant": tenant_id, "address": address}, + ) + await session.execute( + text( + "UPDATE tenant_consumers SET default_address = :address " + "WHERE tenant_id = :tenant AND consumer = :consumer" + ), + {"tenant": tenant_id, "address": address, "consumer": consumer}, + ) + await session.commit() + + yield address + + async with session_factory() as session: + await session.execute( + text( + "UPDATE tenant_consumers SET default_address = NULL " + "WHERE tenant_id = :tenant AND consumer = :consumer" + ), + {"tenant": tenant_id, "consumer": consumer}, + ) + await session.execute( + text("DELETE FROM scribe_addresses WHERE owner_tenant = :tenant AND name = :address"), + {"tenant": tenant_id, "address": address}, + ) + await session.commit() + await engine.dispose() diff --git a/tests/test_tenants.py b/tests/test_tenants.py index 3ed2251..7dde41e 100644 --- a/tests/test_tenants.py +++ b/tests/test_tenants.py @@ -164,6 +164,35 @@ class TestMe: assert response.json()["id"] == TENANT +class TestMeDefaultAddress: + """`GET /tenants/me`'s `default_address` (core-api T-11): the Office's + fallback for a caller that names no address and holds no session + binding — null unless the grant itself has one.""" + + def test_office_caller_with_a_default_sees_it(self, client, default_address_grant): + response = client.get("/tenants/me", headers=office_headers()) + assert response.status_code == 200 + assert response.json()["default_address"] == default_address_grant + + def test_office_caller_without_a_default_sees_null(self, client): + response = client.get("/tenants/me", headers=office_headers()) + assert response.status_code == 200 + assert response.json()["default_address"] is None + + def test_user_caller_sees_null(self, client): + """A user-kind caller carries no consumer, so there is no grant row + to look a default up for — null regardless of any office grant's + own default.""" + with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user: + mock_get_current_user.return_value = { + "preferred_username": "jeroen", + "tatlock_user": TENANT, + } + response = client.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"}) + assert response.status_code == 200 + assert response.json()["default_address"] is None + + class TestList: """`GET /tenants` — the hard boundary: another tenant's existence is never revealed to a tenant-bearing caller.""" diff --git a/tests/test_tenants_migration.py b/tests/test_tenants_migration.py index 3f8f612..8d82429 100644 --- a/tests/test_tenants_migration.py +++ b/tests/test_tenants_migration.py @@ -1,5 +1,6 @@ """ -Tests for the tenant registry migration 007 (core-api T-8, workspace D-73). +Tests for the tenant registry migrations 007 (core-api T-8, workspace D-73) +and 008 (core-api T-11, the default-address column). Runs against `core_api_test` only (see conftest.py — skip when TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`). @@ -186,3 +187,196 @@ class TestMigration007ForeignKeys: await conn.commit() finally: await engine.dispose() + + +class TestMigration008RoundTrip: + """Migration 008 (core-api T-11) must upgrade and downgrade cleanly + (dossier verification #2) — same pattern as TestMigration007RoundTrip, + one revision up.""" + + @pytest.mark.asyncio + async def test_upgrade_adds_default_address_column(self, test_database_url): + result = run_alembic(test_database_url, "upgrade", "head") + assert result.returncode == 0, result.stderr + found = await _scalar( + test_database_url, + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'", + ) + assert found == "default_address" + + @pytest.mark.asyncio + async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url): + baseline = run_alembic(test_database_url, "upgrade", "head") + assert baseline.returncode == 0, baseline.stderr + + down = run_alembic(test_database_url, "downgrade", "007") + assert down.returncode == 0, down.stderr + found = await _scalar( + test_database_url, + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'", + ) + assert found is None + # tenant_consumers itself, and the rest of the registry, must + # survive a downgrade of 008 — proves 008 only touches its own + # column and FK. + assert await _table_exists(test_database_url, "tenant_consumers") + assert await _table_exists(test_database_url, "tenants") + + # Leave the schema at head for the rest of the suite. + up = run_alembic(test_database_url, "upgrade", "008") + assert up.returncode == 0, up.stderr + found = await _scalar( + test_database_url, + "SELECT column_name FROM information_schema.columns " + "WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'", + ) + assert found == "default_address" + + +class TestMigration008ForeignKey: + """The composite FK `(tenant_id, default_address)` -> + `scribe_addresses(owner_tenant, name)` (dossier verification #2/#3): a + default always names a real address in the same tenant.""" + + @pytest.mark.asyncio + async def test_fk_exists(self, test_database_url): + run_alembic(test_database_url, "upgrade", "head") + found = await _scalar( + test_database_url, + "SELECT conname FROM pg_constraint " + "WHERE conname = 'fk_tenant_consumers_default_address' AND contype = 'f'", + ) + assert found == "fk_tenant_consumers_default_address" + + @pytest.mark.asyncio + async def test_fk_refuses_a_default_naming_an_address_that_does_not_exist( + self, test_database_url + ): + """dossier verification #3: "A test inserts a default naming an + address that does not exist and sees the FK refuse it.\"""" + run_alembic(test_database_url, "upgrade", "head") + asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + engine = create_async_engine(asyncpg_url, poolclass=NullPool) + try: + async with engine.connect() as conn: + with pytest.raises(IntegrityError): + await conn.execute( + text( + "UPDATE tenant_consumers SET default_address = 'no-such-address' " + "WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'" + ) + ) + await conn.commit() + finally: + await engine.dispose() + + @pytest.mark.asyncio + async def test_fk_allows_a_null_default_regardless_of_tenant(self, test_database_url): + """A NULL default_address never triggers the composite check + (Postgres's default MATCH SIMPLE) — explicit, since this is the + state every grant but jpmschweitzer/office-claude is left in.""" + run_alembic(test_database_url, "upgrade", "head") + asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + engine = create_async_engine(asyncpg_url, poolclass=NullPool) + try: + async with engine.begin() as conn: + await conn.execute( + text( + "UPDATE tenant_consumers SET default_address = NULL " + "WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'" + ) + ) + finally: + await engine.dispose() + found = await _scalar( + test_database_url, + "SELECT default_address FROM tenant_consumers " + "WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'", + ) + assert found is None + + +class TestMigration008DataStep: + """jpmschweitzer/office-claude defaults to `claude-ai` only if that + address exists at upgrade time (dossier: "an UPDATE ... WHERE EXISTS + does this"); `llm_tester`/office-claude is never touched.""" + + @pytest.mark.asyncio + async def test_is_a_noop_when_claude_ai_is_absent(self, test_database_url): + """core_api_test seeds no scribe_addresses rows, so the data step + must run without error and leave the default null — this is the + state production's own test database is in today.""" + run_alembic(test_database_url, "upgrade", "head") + down = run_alembic(test_database_url, "downgrade", "007") + assert down.returncode == 0, down.stderr + + up = run_alembic(test_database_url, "upgrade", "008") + assert up.returncode == 0, up.stderr + found = await _scalar( + test_database_url, + "SELECT default_address FROM tenant_consumers " + "WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'", + ) + assert found is None + + @pytest.mark.asyncio + async def test_llm_tester_office_claude_stays_null(self, test_database_url): + run_alembic(test_database_url, "upgrade", "head") + found = await _scalar( + test_database_url, + "SELECT default_address FROM tenant_consumers " + "WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'", + ) + assert found is None + + @pytest.mark.asyncio + async def test_sets_claude_ai_when_the_address_exists_at_upgrade_time( + self, test_database_url + ): + """Register claude-ai in jpmschweitzer *before* 008 runs (the + production shape), downgrade to 007 so the data step runs again on + the way back up, then prove the UPDATE actually sets the default + rather than leaving it null regardless — the mutation check's + positive case. Cleans up its own address and resets the default so + the rest of the suite sees the null state it otherwise expects.""" + run_alembic(test_database_url, "upgrade", "head") + down = run_alembic(test_database_url, "downgrade", "007") + assert down.returncode == 0, down.stderr + + asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + engine = create_async_engine(asyncpg_url, poolclass=NullPool) + try: + async with engine.begin() as conn: + await conn.execute( + text( + "INSERT INTO scribe_addresses (owner_tenant, name, created_by) " + "VALUES ('jpmschweitzer', 'claude-ai', 'test-harness')" + ) + ) + + up = run_alembic(test_database_url, "upgrade", "008") + assert up.returncode == 0, up.stderr + + found = await _scalar( + test_database_url, + "SELECT default_address FROM tenant_consumers " + "WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'", + ) + assert found == "claude-ai" + finally: + async with engine.begin() as conn: + await conn.execute( + text( + "UPDATE tenant_consumers SET default_address = NULL " + "WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'" + ) + ) + await conn.execute( + text( + "DELETE FROM scribe_addresses " + "WHERE owner_tenant = 'jpmschweitzer' AND name = 'claude-ai'" + ) + ) + await engine.dispose()