feat(scribe): add the Scribe domain — addresses, send, mailbox, history, find, mark, tag (T-5)
The Office (workspace D-66, D-68, D-73) needs a message bus: an address is
a topic named for an identity, owned by a tenant and a consumer. D-68 puts
it here, in core-api's own database, behind its own auth, following
src/domains/book/.
Migration 006 lands the whole schema by hand (down_revision="005"):
scribe_addresses (owner_tenant + name primary key — no surrogate id, since
no route ever references an address except by name), scribe_messages
(retained; a generated tsvector search column + GIN index on subject/body,
and a GIN index on the topics array for history's lookup), and
scribe_mailbox_entries (one row per recipient address per message, unique
on address+message_id). No data: seeding `jeroen` and `tower-control` is a
deploy step through `register`, not this migration.
Routes, every one behind require_caller("office", "user"), tenant from the
caller (T-4):
- register/list addresses — register is idempotent for the same consumer,
409 address_taken for another consumer in the tenant; list marks which
addresses the caller's consumer owns.
- send — `from` is always the caller's own identity, stamped server-side,
never read from the body; 403 identity_not_owned if that identity is not
a registered address owned by the caller's consumer; 404 unknown_address
(naming the missing ones) if any recipient does not exist in the tenant;
writes one mailbox entry per recipient.
- mailbox / find — the caller's own identity's entries (mailbox: unread
first; find: full text via websearch_to_tsquery), both requiring the
identity be owned by the caller's consumer.
- history — the retained messages sent to an address, oldest first;
reading never consumes or marks, and reads scribe_messages directly
rather than joining through mailbox entries.
- mark / tag / untag — share one PATCH route; an entry outside the
caller's tenant, or addressed to an identity the caller isn't reading
as, answers 404 not_found — indistinguishable from a nonexistent entry.
Every query filters on the caller's tenant (D-73): an address or message
in another tenant answers exactly like one that does not exist, so no id
is ever confirmed across that boundary (test_scribe.py::TestTenantIsolation,
including two tests proving the tenant filter is load-bearing even when
the same address *name* legitimately exists, differently owned, in two
tenants — not just when the caller's own identity is unregistered there).
Messaging only this round: groups, subscriptions, the notification matrix
and delivery are explicitly not built (workspace T-176) — the schema
already carries what `topics`/`tags` need for them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,9 @@ from src.domains.dashboard.models import QuickLink, DashboardWidget # noqa: F40
|
||||
from src.domains.book.models import ( # noqa: F401
|
||||
BookRecord, BookTag, BookRecordLink, BookAttachment, BookEvent,
|
||||
)
|
||||
from src.domains.scribe.models import ( # noqa: F401
|
||||
ScribeAddress, ScribeMessage, ScribeMailboxEntry,
|
||||
)
|
||||
|
||||
# Alembic Config object
|
||||
config = context.config
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Create Scribe tables (dossier core-api T-5, D-66/D-68/D-73)
|
||||
|
||||
Revision ID: 006
|
||||
Revises: 005
|
||||
Create Date: 2026-10-19
|
||||
|
||||
scribe_addresses, scribe_messages, scribe_mailbox_entries — messaging only
|
||||
(no groups, subscriptions, notification matrix or delivery this round;
|
||||
workspace T-176). No data: seeding `jeroen` and `tower-control` is a
|
||||
deploy step through `register`, not this migration.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "006"
|
||||
down_revision: Union[str, None] = "005"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the Scribe tables, their search/topics GIN indexes."""
|
||||
|
||||
op.create_table(
|
||||
"scribe_addresses",
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("name", sa.String(length=63), nullable=False),
|
||||
sa.Column("owner_consumer", sa.String(length=63), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=63), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("owner_tenant", "name"),
|
||||
sa.UniqueConstraint("owner_tenant", "name", name="uq_scribe_addresses_tenant_name"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"scribe_messages",
|
||||
sa.Column("id", sa.Integer(), sa.Identity(always=False), nullable=False),
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("topics", ARRAY(sa.Text()), nullable=False, server_default=sa.text("'{}'::text[]")),
|
||||
sa.Column("from_identity", sa.String(length=63), nullable=False),
|
||||
sa.Column("from_consumer", sa.String(length=63), nullable=True),
|
||||
sa.Column("subject", sa.String(length=500), nullable=True),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("tags", ARRAY(sa.Text()), nullable=False, server_default=sa.text("'{}'::text[]")),
|
||||
sa.Column("reply_to", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["reply_to"], ["scribe_messages.id"], name="fk_scribe_messages_reply_to"),
|
||||
)
|
||||
op.create_index("ix_scribe_messages_owner_tenant", "scribe_messages", ["owner_tenant"])
|
||||
op.create_index(
|
||||
"ix_scribe_messages_topics", "scribe_messages", ["topics"], postgresql_using="gin"
|
||||
)
|
||||
|
||||
# Generated column — 'simple' because the household writes mixed Dutch
|
||||
# and English, matching book_records.search (migration 005). Added via
|
||||
# raw SQL: Alembic's op.add_column has no first-class GENERATED ALWAYS
|
||||
# AS support.
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE scribe_messages
|
||||
ADD COLUMN search tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(subject, '') || ' ' || body)) STORED
|
||||
"""
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scribe_messages_search", "scribe_messages", ["search"], postgresql_using="gin"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"scribe_mailbox_entries",
|
||||
sa.Column("id", sa.Integer(), sa.Identity(always=False), nullable=False),
|
||||
sa.Column("address", sa.String(length=63), nullable=False),
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("message_id", sa.Integer(), nullable=False),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("tags", ARRAY(sa.Text()), nullable=False, server_default=sa.text("'{}'::text[]")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["message_id"], ["scribe_messages.id"], name="fk_scribe_mailbox_entries_message_id"
|
||||
),
|
||||
sa.UniqueConstraint("address", "message_id", name="uq_scribe_mailbox_entries_address_message"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scribe_mailbox_entries_owner_tenant_address",
|
||||
"scribe_mailbox_entries",
|
||||
["owner_tenant", "address"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scribe_mailbox_entries_message_id", "scribe_mailbox_entries", ["message_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the Scribe tables, in dependency order."""
|
||||
op.drop_index("ix_scribe_mailbox_entries_message_id", table_name="scribe_mailbox_entries")
|
||||
op.drop_index(
|
||||
"ix_scribe_mailbox_entries_owner_tenant_address", table_name="scribe_mailbox_entries"
|
||||
)
|
||||
op.drop_table("scribe_mailbox_entries")
|
||||
|
||||
op.drop_index("ix_scribe_messages_search", table_name="scribe_messages")
|
||||
op.drop_index("ix_scribe_messages_topics", table_name="scribe_messages")
|
||||
op.drop_index("ix_scribe_messages_owner_tenant", table_name="scribe_messages")
|
||||
op.drop_table("scribe_messages")
|
||||
|
||||
op.drop_table("scribe_addresses")
|
||||
Reference in New Issue
Block a user