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