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>
86 lines
2.2 KiB
Python
86 lines
2.2 KiB
Python
"""
|
|
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
|