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
+2 -1
View File
@@ -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,
)
# =====================================================================
+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
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
)
+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 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):
+19 -3
View File
@@ -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]:
"""