Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbb924da59 | ||
|
|
ff54fcc94d | ||
|
|
80034bbacf | ||
|
|
008996e030 | ||
|
|
d1641f1480 | ||
|
|
1c4cfc4f1b | ||
|
|
87481a016a |
@@ -91,3 +91,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
||||
|
||||
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.', NULL, '2026-10-19 14:06:27', '2026-10-19 14:06:27.325', '2026-10-19 14:06:27.325', NULL, '0e233f93ee1e85162d05109749d33032', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'status', 'backlog', 'done', NULL, '2026-10-19 14:51:47', '2026-10-19 14:51:47.496', '2026-10-19 14:51:47.496', NULL, '625ca10cfc9bcaf576a2fd52a3fe2792', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'status', 'backlog', 'done', NULL, '2026-10-19 15:48:03', '2026-10-19 15:48:03.951', '2026-10-19 15:48:03.951', NULL, '51baa7767d5176263b817493d40ff5e9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'status', 'backlog', 'done', NULL, '2026-10-19 15:48:04', '2026-10-19 15:48:04.079', '2026-10-19 15:48:04.079', NULL, '252442ffd3a53fbb45bfdd6b24d2752c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -6,3 +6,6 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8NBWVQ53ZX7WMZNE5GJJP4', 'T-6', '2026-10-19 13:24:11.102', '2026-10-19 13:24:11.102', NULL, 'f6351f2d6190ae5fbd9e55e6eadc04e5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'T-7', '2026-10-19 14:06:27.193', '2026-10-19 14:06:27.193', NULL, '09267e8234ec21c6d75767d7cd21bb51', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'T-8', '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, 'd8c7f6122652cb1c3e448f1adedd54ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'T-9', '2026-10-19 17:07:53.702', '2026-10-19 17:07:53.702', NULL, 'f7caf295773331bd290e8f9fd196ce6d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA9K6GSTB09D1SWV649DZ3M', 'T-10', '2026-10-19 17:12:22.406', '2026-10-19 17:12:22.406', NULL, '3bd35329f2ace334c5ef64d8e52ae9cf', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBAZ6KW704AF07ZBR3YC868', 'T-11', '2026-10-19 19:38:11.488', '2026-10-19 19:38:11.488', NULL, '8150ae921a6b2682265917fb669c67e0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
|
||||
@@ -94,3 +94,35 @@ Today a tenant is an unchecked string: the Office header or a JWT''s `tatlock_us
|
||||
|
||||
**Not in v1:** write routes for provisioning (T-195 on the workspace board adds them, or an operator verb); per-consumer verb classes (workspace T-184 — a column on `tenant_consumers` when it lands).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, '756a467f5dc4a84c7ee9080ef21ca5fb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'task', NULL, 'The Book''s final schema and tenant boundary, before 005''s first release', 'The Book''s final schema and its tenant boundary, before core-api''s first release applies migration 005 to production. Split from T-2 (whose remaining verbs stay there). Amend 005 — never run in production — rather than add a migration: per-tenant short outward numbers ("Task 12", workspace D-67), an owner tenant on every record (D-73), and every Book route filtered by it, so the routes are safe to expose the moment they deploy.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 14:06:27.192', '2026-10-19 14:51:47.496', NULL, '8797eb16319aa33c8317b0a1f0defc3d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'task', NULL, 'Caller auth, locked housekeeping routes, and the Book domain', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||
|
||||
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.
|
||||
|
||||
**Code done 2026-10-19**, commits `04ec54b`…`63feb88` (round core-api-T-1-001): `make test` 381 → 430 passed; migration 005 round-trips on `core_api_test`; production `core_api` still at 004; mutation-checked (key compare, the housekeeping lock, the append-only trigger). Follow-ups: T-2 (remaining verbs, short ids, scope tags — amends 005 before its first deploy), T-3 (alembic target). Held for the milestone.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 08:37:53.566', '2026-10-19 15:48:03.950', NULL, '4f256fd107a235ce29e881208eca9fbc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'story', NULL, 'The tenant registry: tenants, consumer grants, and one enforcement point', 'The tenant registry: one table that says which tenants exist, which consumers may act in each, and whether a tenant is active — the anchor every service''s tenancy conforms to (workspace D-73).
|
||||
|
||||
Today a tenant is an unchecked string: the Office header or a JWT''s `tatlock_user` claim is trusted as-is, and the Book and the Scribe file rows under whatever value arrives. A typo''d claim opens a fresh, empty namespace instead of being refused.
|
||||
|
||||
**Tables (migration 007):**
|
||||
- `tenants` — `id` (the slug, the value of `tatlock_user`; same pattern `_require_office_tenant` accepts), `display_name`, `kind` (`household` | `person` | `test`), `status` (`active` | `suspended`), timestamps. Seeded: `jpmschweitzer` (household), `llm_tester` (test).
|
||||
- `tenant_consumers` — a grant: consumer X may act in tenant Y. PK `(tenant_id, consumer)`. Seeded: `office-claude` in both seeded tenants.
|
||||
- Foreign keys from every `owner_tenant` column in the Book and the Scribe to `tenants.id`.
|
||||
|
||||
**Enforcement:** every tenant-scoped operation goes through one helper. No tenant → 400 `tenant_required`. Tenant unknown → 403 `tenant_unknown`. Tenant suspended → 403 `tenant_suspended`. Office caller whose consumer holds no grant in that tenant → 403 `consumer_not_granted`.
|
||||
|
||||
**Read API, for other services to conform to:** `GET /tenants/me` (the caller''s own tenant and its consumer''s grant); `GET /tenants` (service callers without a tenant see all; a tenant-bearing caller sees only its own — the hard boundary).
|
||||
|
||||
**Not in v1:** write routes for provisioning (T-195 on the workspace board adds them, or an operator verb); per-consumer verb classes (workspace T-184 — a column on `tenant_consumers` when it lands).', 'done', 'high', NULL, NULL, NULL, '2026-10-19 14:51:37.641', '2026-10-19 15:48:04.079', NULL, '4adf867d5e7e4424e6a2e97b53f958d1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'task', NULL, 'The mailbox answers by message number, so no client pages to find an entry', '`GET /scribe/mailbox` sorts unread first, then by id descending, behind one `before_id` cursor — two sort keys, one cursor. No cursor choice is safe in every case: paging below the last row is correct whenever a page holds a read entry, but a page of only unread entries (more than 200 unread) skips read entries with higher ids. Both clients that turn a message number into a mailbox entry — the Office (`src/systems/scribe.py` `_resolve_entry_id`) and `toj scribe mark` — page the mailbox to do it.
|
||||
|
||||
**Fix:** a `message_id` query parameter on `GET /scribe/mailbox` that answers the caller''s own entry for that message (zero or one), so neither client pages. Optionally, a cursor that matches the sort (`before` over `(read, id)`) for the listing itself.
|
||||
|
||||
Done when: both clients drop their paging for a single call, and a test seeds an old unread entry ahead of more than a page of read ones and finds a read entry beyond the first page.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 17:07:53.702', '2026-10-19 17:07:53.702', NULL, 'c6599dbbb04302146398615a0caf9e2d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA9K6GSTB09D1SWV649DZ3M', 'task', NULL, 'Scribe messages number per tenant, like the Book', 'Scribe message numbers are global: the playground tenant''s first message was "message 2" because message 1 lives in the household''s tenant. That lets one tenant count another''s mail — a small leak through the hard boundary (workspace D-73), and exactly why the Book numbers per tenant (T-7). Give messages a per-tenant `number` the same way (`owner_tenant`, a per-tenant counter taken FOR UPDATE), answer every route with it, and accept it wherever a message id is taken today. A migration on live data: existing messages get numbers in id order per tenant.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 17:12:22.406', '2026-10-19 17:12:22.406', NULL, '3d3b298bf83b044360d3141137a259a6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBAZ6KW704AF07ZBR3YC868', 'story', NULL, 'A default address on each consumer grant, answered by /tenants/me', '**A default address per consumer grant**, so a client that keeps no MCP session (claude.ai''s connector) need not name itself on every call. The Office uses it when a call names no address and has no session binding (tatlock-office T-5).
|
||||
|
||||
- `tenant_consumers.default_address` — nullable; 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. The Office still checks the consumer owns it.
|
||||
- Migration 008 sets `jpmschweitzer` / `office-claude` → `claude-ai`; `llm_tester` / `office-claude` stays null.
|
||||
- `GET /tenants/me` answers `default_address` — the caller''s own consumer''s default, or null (user-kind callers carry no consumer: null).
|
||||
- No write route; set by migration until provisioning lands (workspace T-195).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 19:38:11.488', '2026-10-19 19:38:11.488', NULL, '0bcc2ad2e1df4cc4410f73b9f55092ca', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
|
||||
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.13.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- **A default address on each consumer grant** (`tenant_consumers.default_address`,
|
||||
migration 008, core-api T-11) — the Office's fallback when a call names no
|
||||
address and holds no session binding, since claude.ai's connector keeps no
|
||||
MCP session between tool calls. 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.
|
||||
`jpmschweitzer`/`office-claude` defaults to `claude-ai` where that address
|
||||
already exists; every other grant stays null. `GET /tenants/me` answers it
|
||||
for the caller's own consumer grant, or null for a user-kind caller or a
|
||||
grant without one; `GET /tenants` is unchanged. No write route this round —
|
||||
provisioning is later.
|
||||
|
||||
## [1.12.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "core-api"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
description = "Core Code API - Infrastructure management and tools API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
+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()
|
||||
|
||||
@@ -164,6 +164,35 @@ class TestMe:
|
||||
assert response.json()["id"] == TENANT
|
||||
|
||||
|
||||
class TestMeDefaultAddress:
|
||||
"""`GET /tenants/me`'s `default_address` (core-api T-11): the Office's
|
||||
fallback for a caller that names no address and holds no session
|
||||
binding — null unless the grant itself has one."""
|
||||
|
||||
def test_office_caller_with_a_default_sees_it(self, client, default_address_grant):
|
||||
response = client.get("/tenants/me", headers=office_headers())
|
||||
assert response.status_code == 200
|
||||
assert response.json()["default_address"] == default_address_grant
|
||||
|
||||
def test_office_caller_without_a_default_sees_null(self, client):
|
||||
response = client.get("/tenants/me", headers=office_headers())
|
||||
assert response.status_code == 200
|
||||
assert response.json()["default_address"] is None
|
||||
|
||||
def test_user_caller_sees_null(self, client):
|
||||
"""A user-kind caller carries no consumer, so there is no grant row
|
||||
to look a default up for — null regardless of any office grant's
|
||||
own default."""
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {
|
||||
"preferred_username": "jeroen",
|
||||
"tatlock_user": TENANT,
|
||||
}
|
||||
response = client.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["default_address"] is None
|
||||
|
||||
|
||||
class TestList:
|
||||
"""`GET /tenants` — the hard boundary: another tenant's existence is
|
||||
never revealed to a tenant-bearing caller."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
Tests for the tenant registry migration 007 (core-api T-8, workspace D-73).
|
||||
Tests for the tenant registry migrations 007 (core-api T-8, workspace D-73)
|
||||
and 008 (core-api T-11, the default-address column).
|
||||
|
||||
Runs against `core_api_test` only (see conftest.py — skip when
|
||||
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
||||
@@ -186,3 +187,196 @@ class TestMigration007ForeignKeys:
|
||||
await conn.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestMigration008RoundTrip:
|
||||
"""Migration 008 (core-api T-11) must upgrade and downgrade cleanly
|
||||
(dossier verification #2) — same pattern as TestMigration007RoundTrip,
|
||||
one revision up."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_adds_default_address_column(self, test_database_url):
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert result.returncode == 0, result.stderr
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'",
|
||||
)
|
||||
assert found == "default_address"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
|
||||
baseline = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert baseline.returncode == 0, baseline.stderr
|
||||
|
||||
down = run_alembic(test_database_url, "downgrade", "007")
|
||||
assert down.returncode == 0, down.stderr
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'",
|
||||
)
|
||||
assert found is None
|
||||
# tenant_consumers itself, and the rest of the registry, must
|
||||
# survive a downgrade of 008 — proves 008 only touches its own
|
||||
# column and FK.
|
||||
assert await _table_exists(test_database_url, "tenant_consumers")
|
||||
assert await _table_exists(test_database_url, "tenants")
|
||||
|
||||
# Leave the schema at head for the rest of the suite.
|
||||
up = run_alembic(test_database_url, "upgrade", "008")
|
||||
assert up.returncode == 0, up.stderr
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'",
|
||||
)
|
||||
assert found == "default_address"
|
||||
|
||||
|
||||
class TestMigration008ForeignKey:
|
||||
"""The composite FK `(tenant_id, default_address)` ->
|
||||
`scribe_addresses(owner_tenant, name)` (dossier verification #2/#3): a
|
||||
default always names a real address in the same tenant."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fk_exists(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT conname FROM pg_constraint "
|
||||
"WHERE conname = 'fk_tenant_consumers_default_address' AND contype = 'f'",
|
||||
)
|
||||
assert found == "fk_tenant_consumers_default_address"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fk_refuses_a_default_naming_an_address_that_does_not_exist(
|
||||
self, test_database_url
|
||||
):
|
||||
"""dossier verification #3: "A test inserts a default naming an
|
||||
address that does not exist and sees the FK refuse it.\""""
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
with pytest.raises(IntegrityError):
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE tenant_consumers SET default_address = 'no-such-address' "
|
||||
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'"
|
||||
)
|
||||
)
|
||||
await conn.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fk_allows_a_null_default_regardless_of_tenant(self, test_database_url):
|
||||
"""A NULL default_address never triggers the composite check
|
||||
(Postgres's default MATCH SIMPLE) — explicit, since this is the
|
||||
state every grant but jpmschweitzer/office-claude is left in."""
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE tenant_consumers SET default_address = NULL "
|
||||
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT default_address FROM tenant_consumers "
|
||||
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'",
|
||||
)
|
||||
assert found is None
|
||||
|
||||
|
||||
class TestMigration008DataStep:
|
||||
"""jpmschweitzer/office-claude defaults to `claude-ai` only if that
|
||||
address exists at upgrade time (dossier: "an UPDATE ... WHERE EXISTS
|
||||
does this"); `llm_tester`/office-claude is never touched."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_a_noop_when_claude_ai_is_absent(self, test_database_url):
|
||||
"""core_api_test seeds no scribe_addresses rows, so the data step
|
||||
must run without error and leave the default null — this is the
|
||||
state production's own test database is in today."""
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
down = run_alembic(test_database_url, "downgrade", "007")
|
||||
assert down.returncode == 0, down.stderr
|
||||
|
||||
up = run_alembic(test_database_url, "upgrade", "008")
|
||||
assert up.returncode == 0, up.stderr
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT default_address FROM tenant_consumers "
|
||||
"WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'",
|
||||
)
|
||||
assert found is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_tester_office_claude_stays_null(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT default_address FROM tenant_consumers "
|
||||
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'",
|
||||
)
|
||||
assert found is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sets_claude_ai_when_the_address_exists_at_upgrade_time(
|
||||
self, test_database_url
|
||||
):
|
||||
"""Register claude-ai in jpmschweitzer *before* 008 runs (the
|
||||
production shape), downgrade to 007 so the data step runs again on
|
||||
the way back up, then prove the UPDATE actually sets the default
|
||||
rather than leaving it null regardless — the mutation check's
|
||||
positive case. Cleans up its own address and resets the default so
|
||||
the rest of the suite sees the null state it otherwise expects."""
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
down = run_alembic(test_database_url, "downgrade", "007")
|
||||
assert down.returncode == 0, down.stderr
|
||||
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO scribe_addresses (owner_tenant, name, created_by) "
|
||||
"VALUES ('jpmschweitzer', 'claude-ai', 'test-harness')"
|
||||
)
|
||||
)
|
||||
|
||||
up = run_alembic(test_database_url, "upgrade", "008")
|
||||
assert up.returncode == 0, up.stderr
|
||||
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT default_address FROM tenant_consumers "
|
||||
"WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'",
|
||||
)
|
||||
assert found == "claude-ai"
|
||||
finally:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE tenant_consumers SET default_address = NULL "
|
||||
"WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"DELETE FROM scribe_addresses "
|
||||
"WHERE owner_tenant = 'jpmschweitzer' AND name = 'claude-ai'"
|
||||
)
|
||||
)
|
||||
await engine.dispose()
|
||||
|
||||
Reference in New Issue
Block a user