""" Scribe Domain Models The Scribe is the household's message bus (workspace D-66, D-68, D-73): a retained pub/sub log where an address is a topic. This round ships the primitives migration 006 creates — addresses, messages, mailbox entries — for direct messaging only; groups, subscriptions, the notification matrix and delivery are later work (workspace T-176). SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following src/domains/book/models.py. Every table carries `owner_tenant` and every query in the service layer filters on it (D-73's hard tenant boundary) — there is no database-level RLS here, so that filter is load-bearing in Python, not just in SQL. """ from datetime import datetime from typing import List, Optional from sqlalchemy import ( Computed, DateTime, ForeignKey, Identity, Integer, String, Text, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR from sqlalchemy.orm import Mapped, mapped_column from src.shared.database import Base # Address name shape, identical to src/domains/auth/callers.py's identity # rule (an address is named for an identity, D-66) — not imported from there # because that module's constants are private to it; kept here so a drift # between the two never happens silently, the regex is asserted equal in # tests/test_scribe.py instead. ADDRESS_NAME_PATTERN = r"^[a-z0-9][a-z0-9-]{0,62}$" RESERVED_ADDRESS_NAMES = {"book", "office", "scheduler", "tatlock"} class ScribeAddress(Base): """ An address: a topic named for an identity. `register` creates it once (D-66) — the pair (owner_tenant, name) is the natural key, so no surrogate id: no route ever references an address except by name. """ __tablename__ = "scribe_addresses" __table_args__ = ( UniqueConstraint("owner_tenant", "name", name="uq_scribe_addresses_tenant_name"), ) # FK to tenants.id added by migration 007 (core-api T-8). owner_tenant: Mapped[str] = mapped_column( String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), primary_key=True ) name: Mapped[str] = mapped_column(String(63), primary_key=True) # Nullable: a "user"-kind caller (direct OIDC, not through the Office) # carries no consumer (src/domains/auth/callers.py), so an address it # registers has none either. owner_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True) created_by: Mapped[str] = mapped_column(String(63), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False, ) def __repr__(self) -> str: return f"" class ScribeMessage(Base): """ A retained message. `topics` is the set of addresses (and, later, groups) it was published to — an integer array is denormalized from the mailbox entries so `history` can read the retained log directly without joining through them (D-66: "topics are retained and re-readable"). Nothing deletes a message. """ __tablename__ = "scribe_messages" id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True) # FK to tenants.id added by migration 007 (core-api T-8). owner_tenant: Mapped[str] = mapped_column( String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False, index=True ) topics: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list) from_identity: Mapped[str] = mapped_column(String(63), nullable=False) from_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True) subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) body: Mapped[str] = mapped_column(Text, nullable=False) tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list) reply_to: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("scribe_messages.id"), nullable=True ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False, ) # Generated by migration 006 (`to_tsvector('simple', ...)`, GIN-indexed), # 'simple' for the same reason as book_records.search: mixed Dutch/ # English content. `Computed` mirrors the migration's SQL. search: Mapped[Optional[str]] = mapped_column( TSVECTOR, Computed("to_tsvector('simple', coalesce(subject, '') || ' ' || body)", persisted=True), nullable=True, ) def __repr__(self) -> str: return f"" class ScribeMailboxEntry(Base): """One row per recipient address per message — what lands in a mailbox.""" __tablename__ = "scribe_mailbox_entries" __table_args__ = ( UniqueConstraint("address", "message_id", name="uq_scribe_mailbox_entries_address_message"), ) id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True) address: Mapped[str] = mapped_column(String(63), nullable=False) # FK to tenants.id added by migration 007 (core-api T-8). owner_tenant: Mapped[str] = mapped_column( String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False ) message_id: Mapped[int] = mapped_column( Integer, ForeignKey("scribe_messages.id"), nullable=False ) read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False, ) def __repr__(self) -> str: return f""