feat(tenants): add a default address on each consumer grant (T-11)

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:05:46 +02:00
co-authored by Claude Sonnet 5
parent 008996e030
commit 80034bbacf
8 changed files with 444 additions and 13 deletions
@@ -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")
+2 -1
View File
@@ -51,7 +51,7 @@ class TenantsController(BaseController):
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
try: try:
tenant, consumers = await service.get_me(session, caller) tenant, consumers, default_address = await service.get_me(session, caller)
except TenantError as error: except TenantError as error:
raise HTTPException( raise HTTPException(
status_code=error.status_code, detail={"code": error.code} status_code=error.status_code, detail={"code": error.code}
@@ -62,6 +62,7 @@ class TenantsController(BaseController):
kind=tenant.kind, kind=tenant.kind,
status=tenant.status, status=tenant.status,
consumers=consumers, consumers=consumers,
default_address=default_address,
) )
# ===================================================================== # =====================================================================
+29 -2
View File
@@ -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 checked known, active, and — for an Office caller — granted to the calling
consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
src/domains/book/models.py. 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 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 sqlalchemy.orm import Mapped, mapped_column
from src.shared.database import Base from src.shared.database import Base
@@ -55,14 +70,26 @@ class Tenant(Base):
class TenantConsumer(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" __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( tenant_id: Mapped[str] = mapped_column(
String(63), ForeignKey("tenants.id"), primary_key=True String(63), ForeignKey("tenants.id"), primary_key=True
) )
consumer: Mapped[str] = mapped_column(String(63), 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( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
+7 -2
View File
@@ -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 D-73). No write routes this round — provisioning is later
(workspace T-195). (workspace T-195).
""" """
from typing import List from typing import List, Optional
from src.shared.base import BaseSchema from src.shared.base import BaseSchema
@@ -21,9 +21,14 @@ class TenantSummary(BaseSchema):
class TenantMeResponse(TenantSummary): class TenantMeResponse(TenantSummary):
"""`GET /tenants/me` — the caller's own tenant plus the consumers """`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] consumers: List[str]
default_address: Optional[str] = None
class TenantListResponse(BaseSchema): class TenantListResponse(BaseSchema):
+19 -3
View File
@@ -81,9 +81,16 @@ class TenantsService:
return tenant 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 """`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_id = await self.require_tenant(session, caller)
tenant = await session.get(Tenant, tenant_id) tenant = await session.get(Tenant, tenant_id)
@@ -93,7 +100,16 @@ class TenantsService:
.order_by(TenantConsumer.consumer) .order_by(TenantConsumer.consumer)
) )
consumers = list((await session.execute(consumers_stmt)).scalars().all()) 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]: async def list_tenants(self, session: AsyncSession, caller: Caller) -> List[Tenant]:
""" """
+86 -4
View File
@@ -225,13 +225,36 @@ async def scribe_session(scribe_schema: str):
@pytest.fixture @pytest.fixture
async def clean_scribe_tables(scribe_session): async def clean_scribe_tables(scribe_session):
"""Scribe service methods commit internally (same reason as Book's """Scribe service methods commit internally (same reason as Book's
clean_book_tables). TRUNCATE all three tables together so Postgres clean_book_tables). TRUNCATE all tables together so Postgres resolves
resolves the FK order (mailbox_entries -> messages, messages' the FK order (mailbox_entries -> messages, messages' self-referencing
self-referencing reply_to) across the one statement.""" 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 from sqlalchemy import text
await scribe_session.execute( 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() await scribe_session.commit()
yield scribe_session 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.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id})
await session.commit() await session.commit()
await engine.dispose() 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()
+29
View File
@@ -164,6 +164,35 @@ class TestMe:
assert response.json()["id"] == TENANT 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: class TestList:
"""`GET /tenants` — the hard boundary: another tenant's existence is """`GET /tenants` — the hard boundary: another tenant's existence is
never revealed to a tenant-bearing caller.""" never revealed to a tenant-bearing caller."""
+195 -1
View File
@@ -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 Runs against `core_api_test` only (see conftest.py — skip when
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`). TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
@@ -186,3 +187,196 @@ class TestMigration007ForeignKeys:
await conn.commit() await conn.commit()
finally: finally:
await engine.dispose() 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()