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:
@@ -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,
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user