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:
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
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"),
|
||||
)
|
||||
|
||||
owner_tenant: Mapped[str] = mapped_column(String(63), 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"<ScribeAddress {self.owner_tenant}/{self.name}>"
|
||||
|
||||
|
||||
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)
|
||||
owner_tenant: Mapped[str] = mapped_column(String(63), 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"<ScribeMessage {self.id} from={self.from_identity}>"
|
||||
|
||||
|
||||
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)
|
||||
owner_tenant: Mapped[str] = mapped_column(String(63), 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"<ScribeMailboxEntry {self.id} address={self.address} message={self.message_id}>"
|
||||
Reference in New Issue
Block a user