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")