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
+128
View File
@@ -7,6 +7,8 @@ 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.
"""
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
@@ -68,6 +70,17 @@ def office_headers(identity: str, consumer: str, tenant: str = TENANT) -> dict:
}
def office_headers_no_identity(consumer: str, tenant: str = TENANT) -> dict:
"""No `X-Office-Identity` at all — core-api T-15's one exemption (an
office caller's `feedback` to `tower-control` alone) is reachable only
through this shape; every other write still needs the header."""
return {
"Authorization": f"Bearer {OFFICE_KEY}",
"X-Office-Consumer": consumer,
"X-Office-Tenant": tenant,
}
def register(client, identity: str, consumer: str, tenant: str = TENANT):
return client.post(
"/scribe/addresses",
@@ -403,6 +416,121 @@ class TestSendValidation:
assert reply.json()["detail"]["code"] == "not_found"
# =============================================================================
# `feedback` needs no address (core-api T-15 / workspace D-70): the one
# shape that may ship with no sender is an office caller's `feedback` to
# `tower-control` alone — nothing broader.
# =============================================================================
class TestFeedbackWithoutIdentity:
def test_feedback_to_tower_control_alone_with_no_identity_succeeds(self, client):
register(client, "tower-control", "office-gemini")
sent = client.post(
"/scribe/messages",
json={
"to": ["tower-control"],
"body": "the find verb has no limit guard",
"tags": ["feedback"],
},
headers=office_headers_no_identity("office-claude"),
)
assert sent.status_code == 201
message = sent.json()
assert message["from_identity"] is None
assert message["from_consumer"] == "office-claude"
assert message["topics"] == ["tower-control"]
assert message["tags"] == ["feedback"]
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox["total"] == 1
assert mailbox["entries"][0]["message"]["from_identity"] is None
assert mailbox["entries"][0]["message"]["id"] == message["id"]
history = client.get(
"/scribe/addresses/tower-control/history",
headers=office_headers("tower-control", "office-gemini"),
).json()
assert history["messages"][0]["from_identity"] is None
found = client.get(
"/scribe/messages",
params={"q": "limit"},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["from_identity"] is None
def test_feedback_with_no_identity_still_needs_a_known_tenant(self, client):
"""No sender is not no tenant (the brief): `_require_tenant` keeps
running on this path, ahead of the identity decision."""
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
headers=office_headers_no_identity("office-claude", tenant="no-such-tenant"),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "tenant_unknown"
def test_feedback_with_no_identity_still_needs_a_granted_consumer(self, client):
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
headers=office_headers_no_identity("office-ungranted"),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "consumer_not_granted"
@pytest.mark.parametrize(
"to,tags",
[
(["tower-control", "jeroen"], ["feedback"]), # a second recipient
(["jeroen"], ["feedback"]), # another recipient, not tower-control
(["tower-control"], []), # no feedback tag
(["tower-control"], ["urgent"]), # tagged, but not feedback
],
ids=[
"second-recipient",
"another-recipient",
"no-feedback-tag",
"tagged-but-not-feedback",
],
)
def test_every_broader_shape_with_no_identity_is_still_400(self, client, to, tags):
response = client.post(
"/scribe/messages",
json={"to": to, "body": "x", "tags": tags},
headers=office_headers_no_identity("office-claude"),
)
assert response.status_code == 400
assert response.json()["detail"]["code"] == "identity_required"
def test_user_kind_caller_gains_nothing_from_the_exemption(self, client):
"""A user-kind caller's identity is never empty (callers.py always
falls back to a name, never ""), so it never reaches the
no-identity branch at all: sending this exact shape is unaffected
by T-15 and still needs an owned address, same as before. Not 400
identity_required — that code names an office caller resolved with
none; this is 403 identity_not_owned, because "jeroen" was never
registered under this (consumer-less) user identity."""
register(client, "tower-control", "office-gemini")
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
mock_get_current_user.return_value = {
"preferred_username": "jeroen",
"tatlock_user": TENANT,
}
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
headers={"Authorization": "Bearer some-jwt"},
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "identity_not_owned"
# =============================================================================
# Isolation (dossier verification #3): the hard tenant boundary, both
# directions — every cross-tenant answer is indistinguishable from "does
+103 -1
View File
@@ -1,5 +1,5 @@
"""
Tests for Scribe migrations 006 and 009.
Tests for Scribe migrations 006, 009 and 010.
Runs against `core_api_test` only (see conftest.py — skip when
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
@@ -27,6 +27,57 @@ async def _table_exists(url: str, table: str) -> bool:
await engine.dispose()
async def _is_nullable(url: str, table: str, column: 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 is_nullable FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() == "YES"
finally:
await engine.dispose()
async def _clear_scribe_messages(url: str) -> None:
"""Truncate just the two row-bearing Scribe tables that 010's downgrade
inspects — never `scribe_addresses`/`tenant_consumers`, which other
modules' seeded grants depend on. Self-contained so this file's
row-level checks never depend on what another test module's data left
behind (there is no FK from either table to `scribe_addresses`)."""
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.begin() as conn:
await conn.execute(text("TRUNCATE scribe_mailbox_entries, scribe_messages"))
finally:
await engine.dispose()
async def _insert_sender_less_message(url: str) -> None:
"""Insert one scribe_messages row with `from_identity` NULL directly —
bypassing the API, which (even under T-15's exemption) always exists
for the one shape it allows; this proves the migration's own
constraint/refusal, not the service's."""
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO scribe_messages (owner_tenant, topics, from_identity, body) "
"VALUES ('jpmschweitzer', ARRAY['tower-control'], NULL, 'no sender')"
)
)
finally:
await engine.dispose()
async def _columns(url: str, table: str) -> set:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
@@ -108,3 +159,54 @@ class TestMigration009RoundTrip:
assert up.returncode == 0, up.stderr
columns_at_head = await _columns(test_database_url, "scribe_mailbox_entries")
assert {"archived_at", "trashed_at"} <= columns_at_head
class TestMigration010RoundTrip:
"""Migration 010 (core-api T-15 / D-70) must upgrade and downgrade
cleanly — dossier verification #2: 009 -> 010 -> 009 -> 010 — and its
downgrade must name a decision about a sender-less row rather than
silently inventing one (the brief: refusing is acceptable, fabricating
a sender is not). `_clear_scribe_messages` scopes each test to its own
row state rather than trusting what another test module left behind."""
@pytest.mark.asyncio
async def test_upgrade_makes_from_identity_nullable(self, test_database_url):
result = run_alembic(test_database_url, "upgrade", "head")
assert result.returncode == 0, result.stderr
assert await _is_nullable(test_database_url, "scribe_messages", "from_identity")
@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
await _clear_scribe_messages(test_database_url) # no sender-less row in the way
down = run_alembic(test_database_url, "downgrade", "009")
assert down.returncode == 0, down.stderr
assert not await _is_nullable(test_database_url, "scribe_messages", "from_identity")
# Leave the schema at head (010 is head) for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr
assert await _is_nullable(test_database_url, "scribe_messages", "from_identity")
@pytest.mark.asyncio
async def test_downgrade_refuses_while_a_sender_less_row_exists(self, test_database_url):
"""The chosen behaviour, named: the downgrade does not run, and the
column stays nullable — nothing silently invents a sender for the
row it found."""
baseline = run_alembic(test_database_url, "upgrade", "head")
assert baseline.returncode == 0, baseline.stderr
await _clear_scribe_messages(test_database_url)
await _insert_sender_less_message(test_database_url)
down = run_alembic(test_database_url, "downgrade", "009")
assert down.returncode != 0
assert "migration 010 downgrade refused" in (down.stdout + down.stderr)
assert await _is_nullable(test_database_url, "scribe_messages", "from_identity")
# Leave the schema at head, clean, for the rest of the suite.
await _clear_scribe_messages(test_database_url)
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr