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:
2026-10-19 15:18:52 +02:00
co-authored by Claude Sonnet 5
parent 739b33b198
commit ccdbc44d67
11 changed files with 1612 additions and 0 deletions
+3
View File
@@ -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")
+9
View File
@@ -0,0 +1,9 @@
"""
Scribe Domain
The household's message bus (workspace D-66, D-68, D-73): addresses, send,
mailbox, history, find, mark, tag. Served under /scribe/*.
"""
from src.domains.scribe.controller import scribe_controller
__all__ = ["scribe_controller"]
+245
View File
@@ -0,0 +1,245 @@
"""
Scribe Controller
The Scribe's messaging verbs (dossier core-api T-5): register, list
(addresses), send, mailbox, history, find, mark, tag/untag. Every route is
behind an Office or user caller (T-1/T-4); 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.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from src.domains.auth.callers import Caller, require_caller
from src.domains.scribe.schemas import (
AddressListResponse,
AddressRegisterRequest,
AddressResponse,
HistoryResponse,
MailboxEntryResponse,
MailboxListResponse,
MailboxPatchRequest,
MessageResponse,
MessageSendRequest,
)
from src.domains.scribe.service import ScribeError, get_scribe_service
from src.shared.base import BaseController
from src.shared.database import get_async_session
from src.shared.logging import get_logger
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger(__name__)
require_scribe_reader = require_caller("office", "user")
require_scribe_writer = require_caller("office", "user", identity_required=True)
def _address_response(address, owned: bool) -> AddressResponse:
return AddressResponse(
name=address.name,
owner_consumer=address.owner_consumer,
created_by=address.created_by,
created_at=address.created_at,
owned=owned,
)
def _message_response(message) -> MessageResponse:
return MessageResponse.model_validate(message, from_attributes=True)
def _entry_response(entry, message) -> MailboxEntryResponse:
return MailboxEntryResponse(
id=entry.id,
address=entry.address,
read_at=entry.read_at,
tags=entry.tags,
created_at=entry.created_at,
message=_message_response(message),
)
class ScribeController(BaseController):
"""Controller for the Scribe domain."""
def __init__(self):
super().__init__(prefix="/scribe", tags=["Scribe"])
def create_router(self) -> APIRouter:
router = APIRouter(prefix=self.prefix, tags=self.tags)
service = get_scribe_service()
# =====================================================================
# register / list addresses
# =====================================================================
@router.post(
"/addresses",
response_model=AddressResponse,
summary="Register an address (idempotent for the same consumer)",
)
async def register_address(
data: AddressRegisterRequest,
caller: Caller = Depends(require_scribe_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
address, owned = await service.register_address(session, data.name, caller)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return _address_response(address, owned)
@router.get(
"/addresses",
response_model=AddressListResponse,
summary="List addresses in the caller's tenant",
)
async def list_addresses(
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
results = await service.list_addresses(session, caller)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return AddressListResponse(
addresses=[_address_response(address, owned) for address, owned in results]
)
# =====================================================================
# send
# =====================================================================
@router.post(
"/messages",
response_model=MessageResponse,
status_code=201,
summary="Send a message to one or more addresses",
)
async def send_message(
data: MessageSendRequest,
caller: Caller = Depends(require_scribe_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
message = await service.send_message(session, data, caller)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return _message_response(message)
# =====================================================================
# find
# =====================================================================
@router.get(
"/messages",
response_model=MailboxListResponse,
summary="Full text search over messages in the caller identity's mailbox",
)
async def find_messages(
q: str = Query(..., min_length=1),
limit: int = Query(50, ge=1, le=200),
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
results, total = await service.find_messages(session, caller, q, limit)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return MailboxListResponse(
entries=[_entry_response(entry, message) for entry, message in results],
total=total,
limit=limit,
)
# =====================================================================
# mailbox
# =====================================================================
@router.get(
"/mailbox",
response_model=MailboxListResponse,
summary="The caller identity's mailbox, unread first",
)
async def get_mailbox(
unread: bool = Query(False),
tag: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
before_id: Optional[int] = Query(None),
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
results, total = await service.get_mailbox(
session, caller, unread=unread, tag=tag, limit=limit, before_id=before_id
)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return MailboxListResponse(
entries=[_entry_response(entry, message) for entry, message in results],
total=total,
limit=limit,
)
@router.patch(
"/mailbox/{entry_id}",
response_model=MailboxEntryResponse,
summary="Mark read/unread, or tag/untag a mailbox entry",
)
async def patch_mailbox_entry(
entry_id: int,
data: MailboxPatchRequest,
caller: Caller = Depends(require_scribe_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
entry, message = await service.patch_mailbox_entry(session, entry_id, data, caller)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return _entry_response(entry, message)
# =====================================================================
# history
# =====================================================================
@router.get(
"/addresses/{name}/history",
response_model=HistoryResponse,
summary="The retained messages sent to an address, oldest first",
)
async def address_history(
name: str,
since_id: Optional[int] = Query(None),
limit: int = Query(100, ge=1, le=500),
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
messages = await service.get_history(session, name, caller, since_id, limit)
except ScribeError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code, **error.extra}
) from error
return HistoryResponse(
address=name,
messages=[_message_response(message) for message in messages],
)
return router
# Create controller instance
scribe_controller = ScribeController()
+136
View File
@@ -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}>"
+85
View File
@@ -0,0 +1,85 @@
"""
Scribe Domain Schemas
Pydantic request/response payloads for the Scribe (D-66, D-68, D-73). This
round ships `register`, `list` (addresses), `send`, `mailbox`, `history`,
`find`, `mark`, `tag`/`untag` — groups, subscriptions, the notification
matrix and delivery are later work (dossier core-api T-5).
"""
from datetime import datetime
from typing import List, Optional
from pydantic import Field
from src.domains.scribe.models import ADDRESS_NAME_PATTERN
from src.shared.base import BaseSchema
class AddressRegisterRequest(BaseSchema):
"""`register` — the name only; owner (tenant, consumer) comes from the caller."""
name: str = Field(..., pattern=ADDRESS_NAME_PATTERN)
class AddressResponse(BaseSchema):
name: str
owner_consumer: Optional[str]
created_by: str
created_at: datetime
owned: bool = Field(..., description="True when the caller's consumer registered this address")
class AddressListResponse(BaseSchema):
addresses: List[AddressResponse]
class MessageSendRequest(BaseSchema):
"""`send` — `from` is never in the body; it is the caller's own identity."""
to: List[str] = Field(..., min_length=1)
subject: Optional[str] = Field(None, max_length=500)
body: str = Field(..., min_length=1)
tags: List[str] = Field(default_factory=list)
reply_to: Optional[int] = None
class MessageResponse(BaseSchema):
id: int
topics: List[str]
from_identity: str
from_consumer: Optional[str]
subject: Optional[str]
body: str
tags: List[str]
reply_to: Optional[int]
created_at: datetime
class MailboxEntryResponse(BaseSchema):
"""`mailbox` / `find` rows, and the result of `mark`/`tag`/`untag`."""
id: int
address: str
read_at: Optional[datetime]
tags: List[str]
created_at: datetime
message: MessageResponse
class MailboxListResponse(BaseSchema):
entries: List[MailboxEntryResponse]
total: int
limit: int
class HistoryResponse(BaseSchema):
address: str
messages: List[MessageResponse]
class MailboxPatchRequest(BaseSchema):
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share one route."""
read: Optional[bool] = None
add_tags: Optional[List[str]] = None
remove_tags: Optional[List[str]] = None
+335
View File
@@ -0,0 +1,335 @@
"""
Scribe Domain Service
Business logic for the Scribe — the household's message bus (D-66, D-68,
D-73). Every method starts by requiring a tenant on the caller and ends by
filtering its query on it: **an address or message in another tenant
answers exactly like one that does not exist** (dossier core-api T-5), so
no method here ever confirms an id across that boundary.
Methods take `session: AsyncSession` per call rather than storing it on
`self`, following src/domains/book/service.py.
"""
from datetime import datetime, timezone
from typing import List, Optional, Tuple
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domains.auth.callers import Caller
from src.domains.scribe.models import (
RESERVED_ADDRESS_NAMES,
ScribeAddress,
ScribeMailboxEntry,
ScribeMessage,
)
from src.domains.scribe.schemas import MailboxPatchRequest, MessageSendRequest
from src.shared.logging import get_logger
logger = get_logger(__name__)
class ScribeError(Exception):
"""Raised for a Scribe-domain failure; the controller maps it to a response."""
def __init__(self, code: str, status_code: int = 400, **extra):
self.code = code
self.status_code = status_code
self.extra = extra
super().__init__(code)
class ScribeService:
"""Service for Scribe operations."""
# =========================================================================
# Internal helpers
# =========================================================================
def _require_tenant(self, caller: Caller) -> str:
"""Every Scribe operation needs a tenant; a caller without one gets
a clear error rather than a query that silently matches nothing."""
if not caller.tenant:
raise ScribeError("tenant_required", 400)
return caller.tenant
def _validate_address_name(self, name: str) -> None:
"""Beyond the schema's regex (shape only): not `group-*`, not reserved.
Mirrors src/domains/auth/callers.py's `_validate_identity` rule —
an address is named for an identity (D-66)."""
if name.startswith("group-") or name in RESERVED_ADDRESS_NAMES:
raise ScribeError("address_invalid", 400)
async def _get_address(self, session: AsyncSession, tenant: str, name: str) -> Optional[ScribeAddress]:
return await session.get(ScribeAddress, (tenant, name))
async def _require_owned_identity(self, session: AsyncSession, caller: Caller, tenant: str) -> None:
"""mailbox/find read the caller's own identity's mailbox: the identity
must be a registered address owned by the caller's consumer, else
403 identity_not_owned — the same refusal `send` uses for `from`."""
address = await self._get_address(session, tenant, caller.identity)
if address is None or address.owner_consumer != caller.consumer:
raise ScribeError("identity_not_owned", 403)
async def _messages_for_entries(
self, session: AsyncSession, entries: List[ScribeMailboxEntry]
) -> dict:
if not entries:
return {}
ids = [entry.message_id for entry in entries]
stmt = select(ScribeMessage).where(ScribeMessage.id.in_(ids))
messages = (await session.execute(stmt)).scalars().all()
return {message.id: message for message in messages}
# =========================================================================
# register / list addresses
# =========================================================================
async def register_address(
self, session: AsyncSession, name: str, caller: Caller
) -> Tuple[ScribeAddress, bool]:
"""`register` — idempotent for the same consumer; 409 address_taken
for another consumer in the tenant (D-66)."""
tenant = self._require_tenant(caller)
self._validate_address_name(name)
existing = await self._get_address(session, tenant, name)
if existing is not None:
if existing.owner_consumer == caller.consumer:
return existing, True
raise ScribeError("address_taken", 409)
address = ScribeAddress(
owner_tenant=tenant,
name=name,
owner_consumer=caller.consumer,
created_by=caller.identity,
)
session.add(address)
await session.commit()
await session.refresh(address)
logger.info(f"Scribe address registered: {tenant}/{name} by={caller.identity}")
return address, True
async def list_addresses(
self, session: AsyncSession, caller: Caller
) -> List[Tuple[ScribeAddress, bool]]:
"""`list` — the caller's tenant; mark which the caller's consumer owns."""
tenant = self._require_tenant(caller)
stmt = (
select(ScribeAddress)
.where(ScribeAddress.owner_tenant == tenant)
.order_by(ScribeAddress.name)
)
addresses = (await session.execute(stmt)).scalars().all()
return [(a, a.owner_consumer == caller.consumer) for a in addresses]
# =========================================================================
# send
# =========================================================================
async def send_message(
self, session: AsyncSession, data: MessageSendRequest, caller: Caller
) -> ScribeMessage:
"""`send` — `from` is the caller's own identity, never the body."""
tenant = self._require_tenant(caller)
from_address = await self._get_address(session, tenant, caller.identity)
if from_address is None or from_address.owner_consumer != caller.consumer:
raise ScribeError("identity_not_owned", 403)
recipients = list(dict.fromkeys(data.to)) # de-dup, keep order
found_stmt = select(ScribeAddress.name).where(
ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients)
)
found = set((await session.execute(found_stmt)).scalars().all())
missing = [name for name in recipients if name not in found]
if missing:
raise ScribeError("unknown_address", 404, names=missing)
if data.reply_to is not None:
reply_target = await session.get(ScribeMessage, data.reply_to)
if reply_target is None or reply_target.owner_tenant != tenant:
raise ScribeError("not_found", 404)
tags = list(dict.fromkeys(data.tags))
message = ScribeMessage(
owner_tenant=tenant,
topics=recipients,
from_identity=caller.identity,
from_consumer=caller.consumer,
subject=data.subject,
body=data.body,
tags=tags,
reply_to=data.reply_to,
)
session.add(message)
await session.flush() # assign message.id
for recipient in recipients:
session.add(
ScribeMailboxEntry(address=recipient, owner_tenant=tenant, message_id=message.id)
)
await session.commit()
await session.refresh(message)
logger.info(
f"Scribe message sent: id={message.id} from={caller.identity} to={recipients}"
)
return message
# =========================================================================
# mailbox / find
# =========================================================================
async def get_mailbox(
self,
session: AsyncSession,
caller: Caller,
unread: Optional[bool] = None,
tag: Optional[str] = None,
limit: int = 50,
before_id: Optional[int] = None,
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
"""`mailbox` — the caller's identity's entries, unread first."""
tenant = self._require_tenant(caller)
await self._require_owned_identity(session, caller, tenant)
conditions = [
ScribeMailboxEntry.owner_tenant == tenant,
ScribeMailboxEntry.address == caller.identity,
]
if unread:
conditions.append(ScribeMailboxEntry.read_at.is_(None))
if tag is not None:
conditions.append(ScribeMailboxEntry.tags.contains([tag]))
if before_id is not None:
conditions.append(ScribeMailboxEntry.id < before_id)
base_query = select(ScribeMailboxEntry).where(*conditions)
count_query = select(func.count()).select_from(base_query.subquery())
total = (await session.execute(count_query)).scalar() or 0
query = (
base_query
.order_by(ScribeMailboxEntry.read_at.is_(None).desc(), ScribeMailboxEntry.id.desc())
.limit(limit)
)
entries = list((await session.execute(query)).scalars().all())
messages_by_id = await self._messages_for_entries(session, entries)
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
async def find_messages(
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
"""`find` — full text over messages in the caller identity's mailbox."""
tenant = self._require_tenant(caller)
await self._require_owned_identity(session, caller, tenant)
tsquery = func.websearch_to_tsquery("simple", q)
base_query = (
select(ScribeMailboxEntry)
.join(ScribeMessage, ScribeMailboxEntry.message_id == ScribeMessage.id)
.where(
ScribeMailboxEntry.owner_tenant == tenant,
ScribeMailboxEntry.address == caller.identity,
ScribeMessage.search.op("@@")(tsquery),
)
)
count_query = select(func.count()).select_from(base_query.subquery())
total = (await session.execute(count_query)).scalar() or 0
query = base_query.order_by(ScribeMailboxEntry.id.desc()).limit(limit)
entries = list((await session.execute(query)).scalars().all())
messages_by_id = await self._messages_for_entries(session, entries)
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
# =========================================================================
# history
# =========================================================================
async def get_history(
self,
session: AsyncSession,
name: str,
caller: Caller,
since_id: Optional[int] = None,
limit: int = 100,
) -> List[ScribeMessage]:
"""`history` — the retained messages sent to that address, oldest
first. Reading never consumes or marks: this reads scribe_messages
directly, never scribe_mailbox_entries."""
tenant = self._require_tenant(caller)
address = await self._get_address(session, tenant, name)
if address is None:
raise ScribeError("unknown_address", 404)
conditions = [
ScribeMessage.owner_tenant == tenant,
ScribeMessage.topics.contains([name]),
]
if since_id is not None:
conditions.append(ScribeMessage.id > since_id)
stmt = (
select(ScribeMessage)
.where(*conditions)
.order_by(ScribeMessage.id.asc())
.limit(limit)
)
return list((await session.execute(stmt)).scalars().all())
# =========================================================================
# mark / tag / untag
# =========================================================================
async def patch_mailbox_entry(
self, session: AsyncSession, entry_id: int, data: MailboxPatchRequest, caller: Caller
) -> Tuple[ScribeMailboxEntry, ScribeMessage]:
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share
this route. An entry outside the caller's tenant, or addressed to
an identity the caller is not reading as, answers 404 not_found —
indistinguishable from a nonexistent entry, so an id never confirms
someone else's mail exists."""
tenant = self._require_tenant(caller)
entry = await session.get(ScribeMailboxEntry, entry_id)
if entry is None or entry.owner_tenant != tenant or entry.address != caller.identity:
raise ScribeError("not_found", 404)
if data.read is True and entry.read_at is None:
entry.read_at = datetime.now(timezone.utc)
elif data.read is False:
entry.read_at = None
if data.add_tags:
current = set(entry.tags)
entry.tags = list(entry.tags) + [t for t in data.add_tags if t not in current]
if data.remove_tags:
remove = set(data.remove_tags)
entry.tags = [t for t in entry.tags if t not in remove]
await session.commit()
await session.refresh(entry)
message = await session.get(ScribeMessage, entry.message_id)
return entry, message
# Singleton instance
_scribe_service: Optional[ScribeService] = None
def get_scribe_service() -> ScribeService:
"""Get singleton Scribe service instance"""
global _scribe_service
if _scribe_service is None:
_scribe_service = ScribeService()
return _scribe_service
+2
View File
@@ -20,6 +20,7 @@ from src.domains.housekeeping import housekeeping_controller
from src.domains.static import static_controller
from src.domains.dashboard import dashboard_controller
from src.domains.book import book_controller
from src.domains.scribe import scribe_controller
# Initialize settings
settings = get_settings()
@@ -108,6 +109,7 @@ app.include_router(housekeeping_controller.router) # /housekeeping/*
app.include_router(static_controller.router) # /static/*
app.include_router(dashboard_controller.router) # /dashboard/*
app.include_router(book_controller.router) # /book/*
app.include_router(scribe_controller.router) # /scribe/*
# Global exception handler
+43
View File
@@ -118,3 +118,46 @@ async def clean_book_tables(book_session):
)
await book_session.commit()
yield book_session
@pytest.fixture(scope="session")
def scribe_schema(test_database_url: str) -> str:
"""Ensure migration 006 (and everything before it) is applied to core_api_test."""
result = run_alembic(test_database_url, "upgrade", "head")
if result.returncode != 0:
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
return test_database_url
@pytest.fixture
async def scribe_session(scribe_schema: str):
"""An AsyncSession bound to core_api_test — same per-test engine pattern
as book_session (see its docstring for why: TestClient drives the app
on a different event loop than pytest-asyncio's fixture setup)."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
asyncpg_url = scribe_schema.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await session.rollback()
await engine.dispose()
@pytest.fixture
async def clean_scribe_tables(scribe_session):
"""Scribe service methods commit internally (same reason as Book's
clean_book_tables). TRUNCATE all three tables together so Postgres
resolves the FK order (mailbox_entries -> messages, messages'
self-referencing reply_to) across the one statement."""
from sqlalchemy import text
await scribe_session.execute(
text("TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses")
)
await scribe_session.commit()
yield scribe_session
+581
View File
@@ -0,0 +1,581 @@
"""
Tests for the Scribe domain (dossier core-api T-5 / D-66, D-68, D-73)
against `core_api_test`.
Follows tests/test_book.py's TestClient pattern: `get_async_session` is
overridden to the test database for every test, building a fresh
engine/session per call because TestClient drives the ASGI app on a
different event loop than pytest-asyncio's fixture setup.
"""
import pytest
from fastapi.testclient import TestClient
from src.domains.auth.callers import _IDENTITY_RE, RESERVED_IDENTITIES
from src.domains.scribe.models import ADDRESS_NAME_PATTERN, RESERVED_ADDRESS_NAMES
from src.main import app
from src.shared.config import get_settings
from src.shared.database import get_async_session
settings = get_settings()
OFFICE_KEY = "test-office-key"
TENANT = "jpmschweitzer"
OTHER_TENANT = "llm_tester"
def test_address_name_rule_matches_callers_identity_rule():
"""models.py deliberately duplicates callers.py's identity shape rather
than importing its private constants (see models.py's comment) — assert
the two never drift apart instead."""
assert ADDRESS_NAME_PATTERN == _IDENTITY_RE.pattern
assert RESERVED_ADDRESS_NAMES == RESERVED_IDENTITIES
@pytest.fixture(autouse=True)
def office_key():
"""Every Scribe test authenticates as the office caller unless it says otherwise."""
original = settings.office_service_key
settings.office_service_key = OFFICE_KEY
yield
settings.office_service_key = original
@pytest.fixture
def client(clean_scribe_tables, test_database_url):
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
async def _override():
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
async with session_factory() as session:
yield session
await engine.dispose()
app.dependency_overrides[get_async_session] = _override
yield TestClient(app)
app.dependency_overrides.pop(get_async_session, None)
def office_headers(identity: str, consumer: str, tenant: str = TENANT) -> dict:
return {
"Authorization": f"Bearer {OFFICE_KEY}",
"X-Office-Identity": identity,
"X-Office-Consumer": consumer,
"X-Office-Tenant": tenant,
}
def register(client, identity: str, consumer: str, tenant: str = TENANT):
return client.post(
"/scribe/addresses",
json={"name": identity},
headers=office_headers(identity, consumer, tenant),
)
# =============================================================================
# Caller matrix — a representative slice; the full matrix is test_callers.py
# and test_book.py's TestCallerMatrixOnBookRoutes already exercises
# require_caller itself end to end on a sibling domain.
# =============================================================================
class TestCallerMatrixOnScribeRoutes:
def test_no_auth_is_401_on_scribe_routes(self, client):
from src.domains.auth.oidc import oidc_config
oidc_config.configure(enabled=True, issuers=[], audiences=[])
try:
response = client.post("/scribe/addresses", json={"name": "jeroen"})
finally:
oidc_config.configure(enabled=False, issuers=[], audiences=[])
assert response.status_code == 401
def test_office_write_without_identity_is_400(self, client):
response = client.post(
"/scribe/addresses",
json={"name": "jeroen"},
headers={
"Authorization": f"Bearer {OFFICE_KEY}",
"X-Office-Consumer": "office-claude",
"X-Office-Tenant": TENANT,
},
)
assert response.status_code == 400
assert response.json()["detail"]["code"] == "identity_required"
def test_office_write_without_tenant_is_400(self, client):
response = client.post(
"/scribe/addresses",
json={"name": "jeroen"},
headers={
"Authorization": f"Bearer {OFFICE_KEY}",
"X-Office-Identity": "jeroen",
"X-Office-Consumer": "office-claude",
},
)
assert response.status_code == 400
assert response.json()["detail"]["code"] == "tenant_required"
def test_scheduler_key_is_403_on_scribe_addresses(self, client):
settings.scheduler_service_key = "test-scheduler-key"
try:
response = client.post(
"/scribe/addresses",
json={"name": "jeroen"},
headers={"Authorization": "Bearer test-scheduler-key"},
)
finally:
settings.scheduler_service_key = ""
assert response.status_code == 403
assert response.json()["detail"]["code"] == "forbidden"
# =============================================================================
# register / list addresses
# =============================================================================
class TestRegisterAndListAddresses:
def test_register_creates_address_owned_by_caller(self, client):
response = register(client, "jeroen", "office-claude")
assert response.status_code == 200
data = response.json()
assert data["name"] == "jeroen"
assert data["owner_consumer"] == "office-claude"
assert data["created_by"] == "jeroen"
assert data["owned"] is True
def test_register_is_idempotent_for_the_same_consumer(self, client):
first = register(client, "jeroen", "office-claude")
second = register(client, "jeroen", "office-claude")
assert first.status_code == 200
assert second.status_code == 200
assert second.json()["owned"] is True
def test_register_by_another_consumer_is_409_address_taken(self, client):
register(client, "jeroen", "office-claude")
response = register(client, "jeroen", "office-gemini")
assert response.status_code == 409
assert response.json()["detail"]["code"] == "address_taken"
@pytest.mark.parametrize("name", ["book", "office", "scheduler", "tatlock", "group-home"])
def test_register_reserved_or_group_name_is_400(self, client, name):
"""The registering identity ("jeroen") is valid and distinct from
the name being registered, so this exercises the service's own
reserved/group- check, not the caller header's identity check."""
response = client.post(
"/scribe/addresses",
json={"name": name},
headers=office_headers("jeroen", "office-claude"),
)
assert response.status_code == 400
assert response.json()["detail"]["code"] == "address_invalid"
@pytest.mark.parametrize("name", ["Jeroen", "-abc", "a_b", ""])
def test_register_malformed_name_is_422(self, client, name):
"""Shape alone is a pydantic concern (422), not a ScribeError (400)."""
response = client.post(
"/scribe/addresses",
json={"name": name},
headers=office_headers("jeroen", "office-claude"),
)
assert response.status_code == 422
def test_list_addresses_marks_ownership(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
as_claude = client.get(
"/scribe/addresses", headers=office_headers("jeroen", "office-claude")
).json()
owned = {a["name"]: a["owned"] for a in as_claude["addresses"]}
assert owned == {"jeroen": True, "tower-control": False}
def test_list_addresses_is_scoped_to_the_callers_tenant(self, client):
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
as_other_tenant = client.get(
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", OTHER_TENANT)
).json()
assert len(as_other_tenant["addresses"]) == 1
# =============================================================================
# The flow (dossier verification #2): register, send, mailbox, mark,
# history, find, from shows identity and consumer.
# =============================================================================
class TestMessagingFlow:
def test_full_flow(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "subject": "status", "body": "all green"},
headers=office_headers("jeroen", "office-claude"),
)
assert sent.status_code == 201
message = sent.json()
assert message["from_identity"] == "jeroen"
assert message["from_consumer"] == "office-claude"
assert message["topics"] == ["tower-control"]
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox["total"] == 1
entry = mailbox["entries"][0]
assert entry["read_at"] is None
assert entry["message"]["id"] == message["id"]
assert entry["message"]["body"] == "all green"
marked = client.patch(
f"/scribe/mailbox/{entry['id']}",
json={"read": True},
headers=office_headers("tower-control", "office-gemini"),
)
assert marked.status_code == 200
assert marked.json()["read_at"] is not None
mailbox_after = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox_after["entries"][0]["read_at"] is not None
history = client.get(
"/scribe/addresses/tower-control/history",
headers=office_headers("tower-control", "office-gemini"),
).json()
assert history["address"] == "tower-control"
assert len(history["messages"]) == 1
assert history["messages"][0]["id"] == message["id"]
found = client.get(
"/scribe/messages",
params={"q": "green"},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["id"] == message["id"]
def test_history_reading_never_marks_or_consumes(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "hi"},
headers=office_headers("jeroen", "office-claude"),
)
client.get(
"/scribe/addresses/tower-control/history",
headers=office_headers("tower-control", "office-gemini"),
)
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox["entries"][0]["read_at"] is None
def test_mailbox_lists_unread_first(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
for body in ("first", "second", "third"):
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": body},
headers=office_headers("jeroen", "office-claude"),
)
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
first_entry_id = mailbox["entries"][0]["id"]
client.patch(
f"/scribe/mailbox/{first_entry_id}",
json={"read": True},
headers=office_headers("tower-control", "office-gemini"),
)
mailbox_after = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
read_states = [e["read_at"] is None for e in mailbox_after["entries"]]
# unread (True) entries sort before the one read (False) entry
assert read_states == sorted(read_states, reverse=True)
def test_tag_then_untag_mailbox_entry(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "hi"},
headers=office_headers("jeroen", "office-claude"),
)
entry_id = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()["entries"][0]["id"]
tagged = client.patch(
f"/scribe/mailbox/{entry_id}",
json={"add_tags": ["urgent"]},
headers=office_headers("tower-control", "office-gemini"),
)
assert tagged.json()["tags"] == ["urgent"]
untagged = client.patch(
f"/scribe/mailbox/{entry_id}",
json={"remove_tags": ["urgent"]},
headers=office_headers("tower-control", "office-gemini"),
)
assert untagged.json()["tags"] == []
# =============================================================================
# send validation
# =============================================================================
class TestSendValidation:
def test_send_to_unknown_address_is_404_naming_missing_ones(self, client):
register(client, "jeroen", "office-claude")
response = client.post(
"/scribe/messages",
json={"to": ["nobody", "tower-control"], "body": "hi"},
headers=office_headers("jeroen", "office-claude"),
)
assert response.status_code == 404
detail = response.json()["detail"]
assert detail["code"] == "unknown_address"
assert set(detail["names"]) == {"nobody", "tower-control"}
def test_send_from_identity_not_registered_is_403(self, client):
register(client, "tower-control", "office-gemini")
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "hi"},
headers=office_headers("jeroen", "office-claude"), # jeroen never registered
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "identity_not_owned"
def test_cannot_send_as_an_address_another_consumer_owns(self, client):
"""dossier verification #3: a consumer cannot send *as* an address
another consumer owns."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
response = client.post(
"/scribe/messages",
json={"to": ["jeroen"], "body": "impersonating"},
# office-gemini claiming to BE jeroen, which office-claude owns
headers=office_headers("jeroen", "office-gemini"),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "identity_not_owned"
def test_send_with_reply_to_in_another_tenant_is_404(self, client):
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "original"},
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
).json()
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
reply = client.post(
"/scribe/messages",
json={
"to": ["tower-control"],
"body": "cross-tenant reply",
"reply_to": sent["id"],
},
headers=office_headers("jeroen", "office-claude", tenant=OTHER_TENANT),
)
assert reply.status_code == 404
assert reply.json()["detail"]["code"] == "not_found"
# =============================================================================
# Isolation (dossier verification #3): the hard tenant boundary, both
# directions — every cross-tenant answer is indistinguishable from "does
# not exist".
# =============================================================================
class TestTenantIsolation:
def test_llm_tester_cannot_list_jpmschweitzer_addresses(self, client):
register(client, "jeroen", "office-claude", tenant=TENANT)
as_other = client.get(
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", OTHER_TENANT)
).json()
assert as_other["addresses"] == []
def test_llm_tester_cannot_send_to_a_jpmschweitzer_address(self, client):
register(client, "tower-control", "office-gemini", tenant=TENANT) # TENANT only
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "leaking?"},
headers=office_headers("jeroen", "office-claude", OTHER_TENANT),
)
# From OTHER_TENANT's point of view, "tower-control" does not exist.
assert response.status_code == 404
assert response.json()["detail"]["code"] == "unknown_address"
def test_llm_tester_cannot_read_mailbox_of_jpmschweitzer_address(self, client):
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "secret"},
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
)
# llm_tester has never registered tower-control, so it cannot even
# bind the identity -> 403, indistinguishable from "no such address".
response = client.get(
"/scribe/mailbox",
headers=office_headers("tower-control", "office-gemini", OTHER_TENANT),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "identity_not_owned"
def test_llm_tester_cannot_see_history_of_a_jpmschweitzer_address(self, client):
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "secret"},
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
)
response = client.get(
"/scribe/addresses/tower-control/history",
headers=office_headers("jeroen", "office-claude", OTHER_TENANT),
)
assert response.status_code == 404
assert response.json()["detail"]["code"] == "unknown_address"
def test_llm_tester_cannot_find_a_message_of_a_jpmschweitzer_address(self, client):
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "unique-secret-word"},
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
)
response = client.get(
"/scribe/messages",
params={"q": "unique-secret-word"},
headers=office_headers("tower-control", "office-gemini", OTHER_TENANT),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "identity_not_owned"
def test_mark_on_an_entry_in_another_tenant_is_404(self, client):
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "secret"},
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
)
entry_id = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini", TENANT)
).json()["entries"][0]["id"]
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
response = client.patch(
f"/scribe/mailbox/{entry_id}",
json={"read": True},
headers=office_headers("tower-control", "office-gemini", OTHER_TENANT),
)
assert response.status_code == 404
assert response.json()["detail"]["code"] == "not_found"
def test_mark_on_someone_elses_entry_within_the_same_tenant_is_404(self, client):
"""Not a tenant crossing, but the same non-disclosure principle:
an entry addressed to another identity answers like it doesn't
exist, rather than confirming it belongs to someone else."""
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "secret"},
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
)
entry_id = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini", TENANT)
).json()["entries"][0]["id"]
response = client.patch(
f"/scribe/mailbox/{entry_id}",
json={"read": True},
headers=office_headers("jeroen", "office-claude", TENANT), # not tower-control
)
assert response.status_code == 404
assert response.json()["detail"]["code"] == "not_found"
def test_mailbox_does_not_leak_entries_from_another_tenants_same_named_address(self, client):
"""The identity-ownership precheck alone is not the isolation
guarantee: a caller legitimately owns "jeroen" in their own tenant,
and a *different* "jeroen" (same name, different owner) exists in
another tenant with mail of its own. The mailbox query's own tenant
filter is what keeps that mail out — this is what mutation check #1
(dossier) targets."""
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
client.post(
"/scribe/messages",
json={"to": ["jeroen"], "body": "for the TENANT jeroen"},
headers=office_headers("tower-control", "office-gemini", tenant=TENANT),
)
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
client.post(
"/scribe/messages",
json={"to": ["jeroen"], "body": "for the OTHER_TENANT jeroen"},
headers=office_headers("tower-control", "office-gemini", tenant=OTHER_TENANT),
)
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("jeroen", "office-claude", TENANT)
).json()
assert mailbox["total"] == 1
assert mailbox["entries"][0]["message"]["body"] == "for the TENANT jeroen"
def test_find_does_not_leak_messages_from_another_tenants_same_named_address(self, client):
"""Same concern as above, for `find` (mutation check #1's other half)."""
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
client.post(
"/scribe/messages",
json={"to": ["jeroen"], "body": "shared-search-term in TENANT"},
headers=office_headers("tower-control", "office-gemini", tenant=TENANT),
)
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
client.post(
"/scribe/messages",
json={"to": ["jeroen"], "body": "shared-search-term in OTHER_TENANT"},
headers=office_headers("tower-control", "office-gemini", tenant=OTHER_TENANT),
)
found = client.get(
"/scribe/messages",
params={"q": "shared-search-term"},
headers=office_headers("jeroen", "office-claude", TENANT),
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["body"] == "shared-search-term in TENANT"
+60
View File
@@ -0,0 +1,60 @@
"""
Tests for Scribe migration 006.
Runs against `core_api_test` only (see conftest.py — skip when
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
Self-contained: starts and ends at head regardless of what ran before it.
"""
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import NullPool
from tests.conftest import run_alembic
async def _table_exists(url: str, table: str) -> bool:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
result = await conn.execute(
text("SELECT to_regclass(:name) IS NOT NULL AS present"),
{"name": table},
)
return bool(result.scalar())
finally:
await engine.dispose()
class TestMigration006RoundTrip:
"""Migration 006 must upgrade and downgrade cleanly (dossier verification #1)."""
@pytest.mark.asyncio
async def test_upgrade_creates_scribe_tables(self, test_database_url):
result = run_alembic(test_database_url, "upgrade", "head")
assert result.returncode == 0, result.stderr
for table in ("scribe_addresses", "scribe_messages", "scribe_mailbox_entries"):
assert await _table_exists(test_database_url, table), f"{table} missing after upgrade"
@pytest.mark.asyncio
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
baseline = run_alembic(test_database_url, "upgrade", "head")
assert baseline.returncode == 0, baseline.stderr
down = run_alembic(test_database_url, "downgrade", "005")
assert down.returncode == 0, down.stderr
assert not await _table_exists(test_database_url, "scribe_addresses")
assert not await _table_exists(test_database_url, "scribe_messages")
assert not await _table_exists(test_database_url, "scribe_mailbox_entries")
# Book tables, created by the migration below this one, must survive
# a downgrade of 006 — proves 006 only touches its own tables.
assert await _table_exists(test_database_url, "book_records")
# Leave the schema at head for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "006")
assert up.returncode == 0, up.stderr
assert await _table_exists(test_database_url, "scribe_addresses")
assert await _table_exists(test_database_url, "scribe_messages")
assert await _table_exists(test_database_url, "scribe_mailbox_entries")