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:
2026-10-19 16:28:08 +02:00
co-authored by Claude Sonnet 5
parent e52c727d99
commit e02173aefb
10 changed files with 533 additions and 44 deletions
+1 -1
View File
@@ -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")