feat(book): tenant boundary and per-tenant numbering on the Book (T-7)
005 has never run in production (still at 004), so it is amended in place rather than patched later by a 007: book_records gains owner_tenant (the tatlock tenant, Caller.tenant from T-4) and number, a short outward id allocated per tenant via a new book_tenant_counters row locked FOR UPDATE in the same transaction as the insert — INSERT ... ON CONFLICT DO NOTHING bootstraps the counter row so a tenant's very first allocation doesn't race its own creation. Unique on (owner_tenant, number); composite index on (owner_tenant, kind, status). 006 applies on top unchanged. Every Book route now requires a tenant (BookService._require_tenant, mirroring the Scribe's own pattern) and filters on it: a record in another tenant answers exactly like one that does not exist, in both directions. link and add's parent convenience both route through _get_or_404, so a link can never reach another tenant — which is also what keeps show's links/backlinks free of their own tenant filter, since every link touching a tenant's record is created tenant-safe already. Responses include number; list accepts number= to find one. Verified: core_api_test downgraded to 004 (with the original migration, to match its already-applied pre-amendment schema), amended, upgraded back to head — clean round trip. 508 tests pass (489 baseline + 19 new: 3 migration-shape, 16 tenant/numbering). Mutation checks confirmed and reverted: dropping list's tenant filter, allocating numbers from a global counter, and dropping link's tenant check on its target each fail the test built to catch it. Raw REST against a dev server on :8777 against core_api_test confirms the same behaviour over the wire: two tenants' first records both land on number 1, and a cross-tenant read answers 404 not_found. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -20,7 +20,7 @@ from src.shared.database import Base
|
||||
from src.domains.auth.models import User, Role, UserRole, UserPreferences, ApiKey # noqa: F401
|
||||
from src.domains.dashboard.models import QuickLink, DashboardWidget # noqa: F401
|
||||
from src.domains.book.models import ( # noqa: F401
|
||||
BookRecord, BookTag, BookRecordLink, BookAttachment, BookEvent,
|
||||
BookRecord, BookTag, BookRecordLink, BookAttachment, BookEvent, BookTenantCounter,
|
||||
)
|
||||
from src.domains.scribe.models import ( # noqa: F401
|
||||
ScribeAddress, ScribeMessage, ScribeMailboxEntry,
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
"""Create Book tables (T-1, D-68/D-69/D-70)
|
||||
"""Create Book tables (T-1, D-68/D-69/D-70; tenant/number amended by T-7)
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-10-19
|
||||
|
||||
The whole Book schema lands in this one migration — book_records,
|
||||
book_tags, book_record_links, book_attachments, book_events — so later
|
||||
rounds (append, remind, reschedule, pause/resume, redact, ...) need no
|
||||
migration of their own for these tables.
|
||||
book_tags, book_record_links, book_attachments, book_events,
|
||||
book_tenant_counters — so later rounds (append, remind, reschedule,
|
||||
pause/resume, redact, ...) need no migration of their own for these tables.
|
||||
|
||||
book_events is append-only: a trigger raises on UPDATE and DELETE, for
|
||||
every role including the owning application. `redact` (D-70 rule 4) is the
|
||||
one sanctioned exception and is a later round; this migration does not
|
||||
soften the trigger.
|
||||
|
||||
core-api T-7: amended before 005's first release (production was still at
|
||||
004) to add book_records.owner_tenant/number and book_tenant_counters,
|
||||
rather than patching them in with a later migration — see T-7's dossier.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
@@ -34,6 +38,8 @@ def upgrade() -> None:
|
||||
op.create_table(
|
||||
"book_records",
|
||||
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("number", sa.Integer(), nullable=False),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("title", sa.String(length=500), nullable=True),
|
||||
sa.Column("body", sa.Text(), nullable=False, server_default=""),
|
||||
@@ -65,10 +71,16 @@ def upgrade() -> None:
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
||||
sa.UniqueConstraint("owner_tenant", "number", name="uq_book_records_tenant_number"),
|
||||
)
|
||||
op.create_index("ix_book_records_kind", "book_records", ["kind"])
|
||||
op.create_index("ix_book_records_status", "book_records", ["status"])
|
||||
op.create_index("ix_book_records_due_at", "book_records", ["due_at"])
|
||||
op.create_index(
|
||||
"ix_book_records_tenant_kind_status",
|
||||
"book_records",
|
||||
["owner_tenant", "kind", "status"],
|
||||
)
|
||||
|
||||
# Generated column — 'simple' because the household writes mixed Dutch
|
||||
# and English, and a language-specific config would stem one of the two
|
||||
@@ -85,6 +97,17 @@ def upgrade() -> None:
|
||||
"ix_book_records_search", "book_records", ["search"], postgresql_using="gin"
|
||||
)
|
||||
|
||||
# One row per tenant: the next `number` to allocate on book_records.
|
||||
# Locked FOR UPDATE in the same transaction as the record insert it
|
||||
# numbers (BookService._allocate_number) — see that method's docstring
|
||||
# for why this shape is safe under concurrency.
|
||||
op.create_table(
|
||||
"book_tenant_counters",
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("next_number", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.PrimaryKeyConstraint("owner_tenant"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"book_tags",
|
||||
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
@@ -165,7 +188,10 @@ def downgrade() -> None:
|
||||
op.drop_index("ix_book_tags_tag", table_name="book_tags")
|
||||
op.drop_table("book_tags")
|
||||
|
||||
op.drop_table("book_tenant_counters")
|
||||
|
||||
op.drop_index("ix_book_records_search", table_name="book_records")
|
||||
op.drop_index("ix_book_records_tenant_kind_status", table_name="book_records")
|
||||
op.drop_index("ix_book_records_due_at", table_name="book_records")
|
||||
op.drop_index("ix_book_records_status", table_name="book_records")
|
||||
op.drop_index("ix_book_records_kind", table_name="book_records")
|
||||
|
||||
Reference in New Issue
Block a user