feat(scribe): accept a feedback message to tower-control with no sender (T-15)

workspace D-70 ("`feedback` needs no address"): the failure channel must
not depend on the thing most likely to break. `scribe_messages.from_identity`
becomes nullable (migration 010); `POST /scribe/messages` accepts a missing
`X-Office-Identity` for exactly one shape — an office caller, `to` is
`["tower-control"]` alone, and `tags` contains `feedback` — stored with
`from_identity` null and `from_consumer` the caller's consumer. Every other
send without an identity is still 400 `identity_required`, enforced in
ScribeService.send_message rather than the caller dependency, since the
decision needs the request body. `MessageResponse.from_identity` is now
optional so mailbox/history/find/message_id answer such a message with a
null sender. Migration 010's downgrade refuses outright while any
sender-less row exists rather than inventing one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 23:37:46 +02:00
co-authored by Claude Sonnet 5
parent 8b30e73661
commit 8ec89336df
7 changed files with 367 additions and 15 deletions
+13 -2
View File
@@ -6,7 +6,11 @@ The Scribe's messaging verbs (dossier core-api T-5): register, list
a mailbox entry and look one up by message number (core-api T-13/T-9).
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.
tenant answers exactly like one that does not exist. `send` is the one
write that does not require an identity at the dependency layer: core-api
T-15 / D-70 lets an office caller's `feedback` to `tower-control` alone go
with no sender, so ScribeService.send_message enforces identity_required
itself, after seeing the body.
"""
from typing import Optional
@@ -35,6 +39,13 @@ logger = get_logger(__name__)
require_scribe_reader = require_caller("office", "user")
require_scribe_writer = require_caller("office", "user", identity_required=True)
# `/messages` POST is not `identity_required` at this layer: core-api T-15 /
# workspace D-70 lets one shape through with no identity (an office
# caller's `feedback` to `tower-control` alone), and that decision needs
# the request body, which a caller dependency never sees. ScribeService
# .send_message is the single place that enforces identity_required for
# every other shape — see its docstring.
require_scribe_sender = require_caller("office", "user")
def _address_response(address, owned: bool) -> AddressResponse:
@@ -127,7 +138,7 @@ class ScribeController(BaseController):
)
async def send_message(
data: MessageSendRequest,
caller: Caller = Depends(require_scribe_writer),
caller: Caller = Depends(require_scribe_sender),
session: AsyncSession = Depends(get_async_session),
):
try:
+6 -1
View File
@@ -90,7 +90,12 @@ class ScribeMessage(Base):
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)
# Nullable since migration 010 (core-api T-15 / D-70): a `feedback`
# message to `tower-control` alone may ship with no sender — the one
# shape the failure channel must not depend on an address for. The
# service layer (ScribeService._is_exempt_feedback) is what actually
# narrows which sends may leave this null.
from_identity: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
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)
+4 -1
View File
@@ -49,7 +49,10 @@ class MessageSendRequest(BaseSchema):
class MessageResponse(BaseSchema):
id: int
topics: List[str]
from_identity: str
# Optional since core-api T-15 / D-70: a sender-less `feedback` message
# to `tower-control` answers with from_identity null, everywhere this
# schema is used (mailbox, history, find, message_id lookup).
from_identity: Optional[str]
from_consumer: Optional[str]
subject: Optional[str]
body: str
+46 -10
View File
@@ -62,6 +62,19 @@ class ScribeService:
except TenantError as error:
raise ScribeError(error.code, error.status_code) from error
def _is_exempt_feedback(self, caller: Caller, recipients: List[str], tags: List[str]) -> bool:
"""core-api T-15 / workspace D-70 ("`feedback` needs no address"):
the one shape that may ship with no sender is an office caller's
`feedback` to `tower-control` alone — nothing broader. `recipients`
and `tags` are the de-duplicated lists `send_message` already
computed, so a second or different recipient, or a message missing
the `feedback` tag, never qualifies."""
return (
caller.kind == "office"
and recipients == ["tower-control"]
and "feedback" in tags
)
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 —
@@ -142,14 +155,38 @@ class ScribeService:
async def send_message(
self, session: AsyncSession, data: MessageSendRequest, caller: Caller
) -> ScribeMessage:
"""`send` — `from` is the caller's own identity, never the body."""
"""`send` — `from` is the caller's own identity, never the body.
core-api T-15 / workspace D-70: `require_scribe_sender` (the
controller's dependency for this route) no longer enforces
`identity_required` itself, because that decision depends on the
body (`to`, `tags`), which a caller dependency cannot see. This
method is therefore the single place that enforces it: a missing
identity (`caller.identity == ""`, which only an office caller with
no `X-Office-Identity` header can produce) is allowed through
exactly for `_is_exempt_feedback`'s shape, and is still
`identity_required` for every other send — the tenant check stays
on this path too (no sender is not no tenant)."""
tenant = await self._require_tenant(session, 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
tags = list(dict.fromkeys(data.tags))
no_identity = caller.identity == ""
exempt = no_identity and self._is_exempt_feedback(caller, recipients, tags)
if no_identity and not exempt:
raise ScribeError("identity_required", 400)
if exempt:
from_identity = None
from_consumer = caller.consumer
else:
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)
from_identity = caller.identity
from_consumer = caller.consumer
found_stmt = select(ScribeAddress.name).where(
ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients)
)
@@ -163,13 +200,11 @@ class ScribeService:
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,
from_identity=from_identity,
from_consumer=from_consumer,
subject=data.subject,
body=data.body,
tags=tags,
@@ -187,7 +222,8 @@ class ScribeService:
await session.refresh(message)
logger.info(
f"Scribe message sent: id={message.id} from={caller.identity} to={recipients}"
f"Scribe message sent: id={message.id} from={from_identity} "
f"from_consumer={from_consumer} to={recipients}"
)
return message