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:
+86
-4
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user