Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d82535112e | ||
|
|
2e825d9080 | ||
|
|
8ec89336df | ||
|
|
8b30e73661 | ||
|
|
7c0ff2bfe0 | ||
|
|
44c5facc65 | ||
|
|
e25a3ab4f4 | ||
|
|
7c67a4a46b | ||
|
|
0bba3baf32 | ||
|
|
c21c03c289 | ||
|
|
fca545aa49 |
@@ -93,3 +93,72 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'status', 'backlog', 'done', NULL, '2026-10-19 14:51:47', '2026-10-19 14:51:47.496', '2026-10-19 14:51:47.496', NULL, '625ca10cfc9bcaf576a2fd52a3fe2792', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'status', 'backlog', 'done', NULL, '2026-10-19 15:48:03', '2026-10-19 15:48:03.951', '2026-10-19 15:48:03.951', NULL, '51baa7767d5176263b817493d40ff5e9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'status', 'backlog', 'done', NULL, '2026-10-19 15:48:04', '2026-10-19 15:48:04.079', '2026-10-19 15:48:04.079', NULL, '252442ffd3a53fbb45bfdd6b24d2752c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBAZ6KW704AF07ZBR3YC868', 'description', '**A default address per consumer grant**, so a client that keeps no MCP session (claude.ai''s connector) need not name itself on every call. The Office uses it when a call names no address and has no session binding (tatlock-office T-5).
|
||||
|
||||
- `tenant_consumers.default_address` — nullable; a composite foreign key `(tenant_id, default_address)` → `scribe_addresses(owner_tenant, name)`, so a default always names a real address in the same tenant. The Office still checks the consumer owns it.
|
||||
- Migration 008 sets `jpmschweitzer` / `office-claude` → `claude-ai`; `llm_tester` / `office-claude` stays null.
|
||||
- `GET /tenants/me` answers `default_address` — the caller''s own consumer''s default, or null (user-kind callers carry no consumer: null).
|
||||
- No write route; set by migration until provisioning lands (workspace T-195).', '**A default address per consumer grant**, so a client that keeps no MCP session (claude.ai''s connector) need not name itself on every call. The Office uses it when a call names no address and has no session binding (tatlock-office T-5).
|
||||
|
||||
- `tenant_consumers.default_address` — nullable; a composite foreign key `(tenant_id, default_address)` → `scribe_addresses(owner_tenant, name)`, so a default always names a real address in the same tenant. The Office still checks the consumer owns it.
|
||||
- Migration 008 sets `jpmschweitzer` / `office-claude` → `claude-ai`; `llm_tester` / `office-claude` stays null.
|
||||
- `GET /tenants/me` answers `default_address` — the caller''s own consumer''s default, or null (user-kind callers carry no consumer: null).
|
||||
- No write route; set by migration until provisioning lands (workspace T-195).
|
||||
|
||||
Live in core-api 1.13.0; production migrated to 008 — jpmschweitzer/office-claude → claude-ai, llm_tester none; /tenants/me answers it.', NULL, '2026-10-19 20:16:05', '2026-10-19 20:16:05.428', '2026-10-19 20:16:05.428', NULL, 'e1691631641e5a8a37743c8a53fe6c75', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBAZ6KW704AF07ZBR3YC868', 'status', 'backlog', 'done', NULL, '2026-10-19 20:16:05', '2026-10-19 20:16:05.567', '2026-10-19 20:16:05.567', NULL, 'd4fb4422de5f1d47f6ab52904623cd0d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'description', '`GET /scribe/mailbox` sorts unread first, then by id descending, behind one `before_id` cursor — two sort keys, one cursor. No cursor choice is safe in every case: paging below the last row is correct whenever a page holds a read entry, but a page of only unread entries (more than 200 unread) skips read entries with higher ids. Both clients that turn a message number into a mailbox entry — the Office (`src/systems/scribe.py` `_resolve_entry_id`) and `toj scribe mark` — page the mailbox to do it.
|
||||
|
||||
**Fix:** a `message_id` query parameter on `GET /scribe/mailbox` that answers the caller''s own entry for that message (zero or one), so neither client pages. Optionally, a cursor that matches the sort (`before` over `(read, id)`) for the listing itself.
|
||||
|
||||
Done when: both clients drop their paging for a single call, and a test seeds an old unread entry ahead of more than a page of read ones and finds a read entry beyond the first page.', '`GET /scribe/mailbox` sorts unread first, then by id descending, behind one `before_id` cursor — two sort keys, one cursor. No cursor choice is safe in every case: paging below the last row is correct whenever a page holds a read entry, but a page of only unread entries (more than 200 unread) skips read entries with higher ids. Both clients that turn a message number into a mailbox entry — the Office (`src/systems/scribe.py` `_resolve_entry_id`) and `toj scribe mark` — page the mailbox to do it.
|
||||
|
||||
**Fix:** a `message_id` query parameter on `GET /scribe/mailbox` that answers the caller''s own entry for that message (zero or one), so neither client pages. Optionally, a cursor that matches the sort (`before` over `(read, id)`) for the listing itself.
|
||||
|
||||
Done when: both clients drop their paging for a single call, and a test seeds an old unread entry ahead of more than a page of read ones and finds a read entry beyond the first page.
|
||||
|
||||
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.211', '2026-10-19 20:58:36.211', NULL, 'dd28daf8baefbf8c60d5842445ac6672', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'status', 'backlog', 'done', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.314', '2026-10-19 20:58:36.314', NULL, '83fae52bf6b40a1dcef4a89b9d8285d9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'description', 'The user wants a way to get rid of a message that is no longer useful. A message is retained and shared by every recipient (workspace D-66 — history is how agents rebuild context), so deletion is **per mailbox**, in D-70''s vocabulary:
|
||||
|
||||
- **archive / unarchive** — the entry leaves the default mailbox listing; still in `history`, still findable.
|
||||
- **trash / restore** — the entry leaves the mailbox, restorable; trashed entries are listed only when asked for.
|
||||
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket.
|
||||
|
||||
**core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.', 'The user wants a way to get rid of a message that is no longer useful. A message is retained and shared by every recipient (workspace D-66 — history is how agents rebuild context), so deletion is **per mailbox**, in D-70''s vocabulary:
|
||||
|
||||
- **archive / unarchive** — the entry leaves the default mailbox listing; still in `history`, still findable.
|
||||
- **trash / restore** — the entry leaves the mailbox, restorable; trashed entries are listed only when asked for.
|
||||
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket.
|
||||
|
||||
**core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.
|
||||
|
||||
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.431', '2026-10-19 20:58:36.431', NULL, 'be754e2e77e34e20c3b7bf511adefdce', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'status', 'backlog', 'done', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.539', '2026-10-19 20:58:36.539', NULL, '1673ea85b251b578be2248e3d3fe6f54', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'description', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||
|
||||
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
|
||||
|
||||
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||
|
||||
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
|
||||
|
||||
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.
|
||||
|
||||
**When the Book''s archive/trash verbs ship:** its `view=archive` filter (`src/domains/book/service.py`) matches `archived_at IS NOT NULL` alone, so a record both archived and trashed would show there. The Scribe''s (1.14.0) is archived and not trashed — trash wins. Make the Book agree. Found in round core-api-T-13-001.', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.679', '2026-10-19 20:58:36.679', NULL, 'ab2af45b461fbd0e84d422e625afaff2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -9,3 +9,7 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'T-9', '2026-10-19 17:07:53.702', '2026-10-19 17:07:53.702', NULL, 'f7caf295773331bd290e8f9fd196ce6d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA9K6GSTB09D1SWV649DZ3M', 'T-10', '2026-10-19 17:12:22.406', '2026-10-19 17:12:22.406', NULL, '3bd35329f2ace334c5ef64d8e52ae9cf', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBAZ6KW704AF07ZBR3YC868', 'T-11', '2026-10-19 19:38:11.488', '2026-10-19 19:38:11.488', NULL, '8150ae921a6b2682265917fb669c67e0', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBHXYDJEEDQEADCP3JTR7BR', 'T-12', '2026-10-19 20:08:36.204', '2026-10-19 20:08:36.204', NULL, '087d8b4674d3e12ca3b274fc4e255ade', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'T-13', '2026-10-19 20:35:21.053', '2026-10-19 20:35:21.053', NULL, 'aab7f828f5f01bd8310c85f53ff0dc19', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'T-14', '2026-10-19 20:58:36.809', '2026-10-19 20:58:36.809', NULL, '65086d233f5bc652cec0aa79dcaec2c4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBZZR0RQKSAJ5FKF4X540KC', 'T-15', '2026-10-19 21:10:00.967', '2026-10-19 21:10:00.967', NULL, '799679cb2e45f2a0c5ea07da08aa93e1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
|
||||
@@ -126,3 +126,76 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
|
||||
- Migration 008 sets `jpmschweitzer` / `office-claude` → `claude-ai`; `llm_tester` / `office-claude` stays null.
|
||||
- `GET /tenants/me` answers `default_address` — the caller''s own consumer''s default, or null (user-kind callers carry no consumer: null).
|
||||
- No write route; set by migration until provisioning lands (workspace T-195).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 19:38:11.488', '2026-10-19 19:38:11.488', NULL, '0bcc2ad2e1df4cc4410f73b9f55092ca', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBHXYDJEEDQEADCP3JTR7BR', 'task', NULL, 'A failing database test under pytest -v prints the test database password', 'Found in round core-api-T-11-001: when a test that takes the `test_database_url` fixture fails under `pytest -v`, pytest shows the fixture''s value in the traceback — the `core_api_test` URL with its password. Local test credential, not production''s, but Rule 3 draws no line on severity, and every debugging session that lets one fail under -v repeats it. Fix the fixture so its value never renders (a repr-safe wrapper, or pass the parts rather than the URL), and say in CLAUDE.md how to debug a DB test without it.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 20:08:36.204', '2026-10-19 20:08:36.204', NULL, '0ed2ee8eb049c3f0b32d57c0a37b3f6b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBAZ6KW704AF07ZBR3YC868', 'story', NULL, 'A default address on each consumer grant, answered by /tenants/me', '**A default address per consumer grant**, so a client that keeps no MCP session (claude.ai''s connector) need not name itself on every call. The Office uses it when a call names no address and has no session binding (tatlock-office T-5).
|
||||
|
||||
- `tenant_consumers.default_address` — nullable; a composite foreign key `(tenant_id, default_address)` → `scribe_addresses(owner_tenant, name)`, so a default always names a real address in the same tenant. The Office still checks the consumer owns it.
|
||||
- Migration 008 sets `jpmschweitzer` / `office-claude` → `claude-ai`; `llm_tester` / `office-claude` stays null.
|
||||
- `GET /tenants/me` answers `default_address` — the caller''s own consumer''s default, or null (user-kind callers carry no consumer: null).
|
||||
- No write route; set by migration until provisioning lands (workspace T-195).
|
||||
|
||||
Live in core-api 1.13.0; production migrated to 008 — jpmschweitzer/office-claude → claude-ai, llm_tester none; /tenants/me answers it.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 19:38:11.488', '2026-10-19 20:16:05.428', NULL, '5ce494e5d0f29bf5fc2371d4f1c0c4a1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBAZ6KW704AF07ZBR3YC868', 'story', NULL, 'A default address on each consumer grant, answered by /tenants/me', '**A default address per consumer grant**, so a client that keeps no MCP session (claude.ai''s connector) need not name itself on every call. The Office uses it when a call names no address and has no session binding (tatlock-office T-5).
|
||||
|
||||
- `tenant_consumers.default_address` — nullable; a composite foreign key `(tenant_id, default_address)` → `scribe_addresses(owner_tenant, name)`, so a default always names a real address in the same tenant. The Office still checks the consumer owns it.
|
||||
- Migration 008 sets `jpmschweitzer` / `office-claude` → `claude-ai`; `llm_tester` / `office-claude` stays null.
|
||||
- `GET /tenants/me` answers `default_address` — the caller''s own consumer''s default, or null (user-kind callers carry no consumer: null).
|
||||
- No write route; set by migration until provisioning lands (workspace T-195).
|
||||
|
||||
Live in core-api 1.13.0; production migrated to 008 — jpmschweitzer/office-claude → claude-ai, llm_tester none; /tenants/me answers it.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 19:38:11.488', '2026-10-19 20:16:05.567', NULL, 'fe9a3bd6efdd20903e4e7563c59f4bba', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'story', NULL, 'Archive and trash a message in your own mailbox', 'The user wants a way to get rid of a message that is no longer useful. A message is retained and shared by every recipient (workspace D-66 — history is how agents rebuild context), so deletion is **per mailbox**, in D-70''s vocabulary:
|
||||
|
||||
- **archive / unarchive** — the entry leaves the default mailbox listing; still in `history`, still findable.
|
||||
- **trash / restore** — the entry leaves the mailbox, restorable; trashed entries are listed only when asked for.
|
||||
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket.
|
||||
|
||||
**core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 20:35:21.053', '2026-10-19 20:35:21.053', NULL, 'd4fe2d5d06329b627a04dec089ca8a8b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'task', NULL, 'The mailbox answers by message number, so no client pages to find an entry', '`GET /scribe/mailbox` sorts unread first, then by id descending, behind one `before_id` cursor — two sort keys, one cursor. No cursor choice is safe in every case: paging below the last row is correct whenever a page holds a read entry, but a page of only unread entries (more than 200 unread) skips read entries with higher ids. Both clients that turn a message number into a mailbox entry — the Office (`src/systems/scribe.py` `_resolve_entry_id`) and `toj scribe mark` — page the mailbox to do it.
|
||||
|
||||
**Fix:** a `message_id` query parameter on `GET /scribe/mailbox` that answers the caller''s own entry for that message (zero or one), so neither client pages. Optionally, a cursor that matches the sort (`before` over `(read, id)`) for the listing itself.
|
||||
|
||||
Done when: both clients drop their paging for a single call, and a test seeds an old unread entry ahead of more than a page of read ones and finds a read entry beyond the first page.
|
||||
|
||||
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 17:07:53.702', '2026-10-19 20:58:36.211', NULL, '464c90f9c8ff84163446b97f2e5fd2ad', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'task', NULL, 'The mailbox answers by message number, so no client pages to find an entry', '`GET /scribe/mailbox` sorts unread first, then by id descending, behind one `before_id` cursor — two sort keys, one cursor. No cursor choice is safe in every case: paging below the last row is correct whenever a page holds a read entry, but a page of only unread entries (more than 200 unread) skips read entries with higher ids. Both clients that turn a message number into a mailbox entry — the Office (`src/systems/scribe.py` `_resolve_entry_id`) and `toj scribe mark` — page the mailbox to do it.
|
||||
|
||||
**Fix:** a `message_id` query parameter on `GET /scribe/mailbox` that answers the caller''s own entry for that message (zero or one), so neither client pages. Optionally, a cursor that matches the sort (`before` over `(read, id)`) for the listing itself.
|
||||
|
||||
Done when: both clients drop their paging for a single call, and a test seeds an old unread entry ahead of more than a page of read ones and finds a read entry beyond the first page.
|
||||
|
||||
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', 'done', 'medium', NULL, NULL, NULL, '2026-10-19 17:07:53.702', '2026-10-19 20:58:36.314', NULL, 'd49072b54cd82406d0e4cc7e999ff331', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'story', NULL, 'Archive and trash a message in your own mailbox', 'The user wants a way to get rid of a message that is no longer useful. A message is retained and shared by every recipient (workspace D-66 — history is how agents rebuild context), so deletion is **per mailbox**, in D-70''s vocabulary:
|
||||
|
||||
- **archive / unarchive** — the entry leaves the default mailbox listing; still in `history`, still findable.
|
||||
- **trash / restore** — the entry leaves the mailbox, restorable; trashed entries are listed only when asked for.
|
||||
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket.
|
||||
|
||||
**core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.
|
||||
|
||||
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 20:35:21.053', '2026-10-19 20:58:36.431', NULL, 'c21eed83c0a16a0affce7a5d53935efc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'story', NULL, 'Archive and trash a message in your own mailbox', 'The user wants a way to get rid of a message that is no longer useful. A message is retained and shared by every recipient (workspace D-66 — history is how agents rebuild context), so deletion is **per mailbox**, in D-70''s vocabulary:
|
||||
|
||||
- **archive / unarchive** — the entry leaves the default mailbox listing; still in `history`, still findable.
|
||||
- **trash / restore** — the entry leaves the mailbox, restorable; trashed entries are listed only when asked for.
|
||||
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket.
|
||||
|
||||
**core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.
|
||||
|
||||
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 20:35:21.053', '2026-10-19 20:58:36.539', NULL, 'd12ebcdf5e52795a05a51c0620565172', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||
|
||||
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
|
||||
|
||||
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.
|
||||
|
||||
**When the Book''s archive/trash verbs ship:** its `view=archive` filter (`src/domains/book/service.py`) matches `archived_at IS NOT NULL` alone, so a record both archived and trashed would show there. The Scribe''s (1.14.0) is archived and not trashed — trash wins. Make the Book agree. Found in round core-api-T-13-001.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 20:58:36.678', NULL, 'fb33d075391ff2771a3361218f0035b7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'task', NULL, 'Migration round-trip tests leave core_api_test behind head', 'Each migration file''s round-trip test re-upgrades to its own revision, not to head (tests/test_tenants_migration.py ends on 008), so since 009 a full suite run leaves core_api_test one behind head and the next manual check starts from a stale schema. End every round-trip test on head. Found in round core-api-T-13-001.', 'backlog', 'low', NULL, NULL, NULL, '2026-10-19 20:58:36.809', '2026-10-19 20:58:36.809', NULL, '52f32db6c5ddcdd77f3eb995a64e8ba5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBZZR0RQKSAJ5FKF4X540KC', 'story', NULL, 'A feedback message to tower-control may have no sender', 'Adopted by the user (workspace D-70, the feedback paragraph; tatlock-office T-6): the Office''s failure channel must not depend on identity. A Scribe message may be stored with no sender address — from_identity null, from_consumer named — only when it is feedback to tower-control alone (tagged feedback, addressed to tower-control and nothing else). Every other send still requires the caller''s own address. Responses carry the null sender as such. The Office (tatlock-office T-6) sends it.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 21:10:00.967', '2026-10-19 21:10:00.967', NULL, '46c72fcabe09acd063b713da5aef66f2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
|
||||
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- **A `feedback` message to `tower-control` may have no sender** (`scribe_messages.from_identity`
|
||||
nullable, migration 010, core-api T-15 / workspace D-70) — the failure channel must not depend
|
||||
on the thing most likely to break. `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`, and
|
||||
a `user`-kind caller gains nothing from the exemption. `MessageResponse.from_identity` is now
|
||||
optional, so `mailbox`, `history`, `find` and the `message_id` lookup all answer such a message
|
||||
with a null sender.
|
||||
|
||||
## [1.14.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Archive and trash a message in your own mailbox** (`scribe_mailbox_entries.archived_at`
|
||||
and `trashed_at`, migration 009, core-api T-13) — deletion is per mailbox entry, never per
|
||||
message, since a message is retained and shared by every recipient (D-66/D-70). `PATCH
|
||||
/scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read` (true sets
|
||||
the timestamp, false clears it); the response carries both states. `GET /scribe/mailbox`
|
||||
takes `view`: `inbox` (default, neither archived nor trashed), `archive` (archived, not
|
||||
trashed), `trash` (trashed, regardless of `archived_at`), `all`. `find` now excludes trashed
|
||||
entries.
|
||||
- **Look up a mailbox entry by message number** (core-api T-9) — `GET
|
||||
/scribe/mailbox?message_id=N` answers the caller's own entry for that message, zero or one,
|
||||
in any view, bypassing `unread`/`tag`/`before_id` so no client pages the unread-first/
|
||||
id-descending listing to find one.
|
||||
|
||||
## [1.13.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Archive and trash a message in your own mailbox (core-api T-13)
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-10-19
|
||||
|
||||
Deletion is per mailbox entry, never per message (workspace D-66/D-70): a
|
||||
message is retained and shared by every recipient, so only the entry that
|
||||
represents one recipient's copy can be archived or trashed. `archived_at`
|
||||
and `trashed_at` are independent nullable timestamps on
|
||||
`scribe_mailbox_entries`, mirroring `book_records.archived_at`/
|
||||
`trashed_at` (migration 005) — the service layer's `view=trash` takes
|
||||
priority over `view=archive` when both are set, so the column pair alone
|
||||
does not encode that precedence.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "009"
|
||||
down_revision: Union[str, None] = "008"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add the two nullable timestamp columns. No data step: every existing
|
||||
entry is neither archived nor trashed."""
|
||||
op.add_column(
|
||||
"scribe_mailbox_entries",
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scribe_mailbox_entries",
|
||||
sa.Column("trashed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop both columns."""
|
||||
op.drop_column("scribe_mailbox_entries", "trashed_at")
|
||||
op.drop_column("scribe_mailbox_entries", "archived_at")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""A feedback message to tower-control may have no sender (core-api T-15)
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2026-10-19
|
||||
|
||||
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.
|
||||
`scribe_messages.from_identity` becomes nullable to hold it — the service
|
||||
layer (core-api T-15) is what actually narrows which sends may leave it
|
||||
null; this migration only makes the column able to carry that.
|
||||
|
||||
No data step: every existing row already has a sender.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "010"
|
||||
down_revision: Union[str, None] = "009"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Drop the NOT NULL constraint. No data step needed."""
|
||||
op.alter_column(
|
||||
"scribe_messages",
|
||||
"from_identity",
|
||||
existing_type=sa.String(length=63),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Refuse outright if any message was stored with no sender — there is
|
||||
no real sender to invent, and fabricating one would misattribute a
|
||||
retained message (migration 006: nothing deletes a message). Delete or
|
||||
backfill those rows by hand first if this downgrade is genuinely
|
||||
wanted; `ALTER COLUMN ... SET NOT NULL` would refuse anyway once nulls
|
||||
exist, but the explicit check names the reason rather than surfacing it
|
||||
as a generic constraint-violation error."""
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
sender_less_count integer;
|
||||
BEGIN
|
||||
SELECT count(*) INTO sender_less_count
|
||||
FROM scribe_messages WHERE from_identity IS NULL;
|
||||
IF sender_less_count > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'migration 010 downgrade refused: % scribe_messages row(s) have no from_identity (core-api T-15 / D-70) - delete or backfill them before downgrading',
|
||||
sender_less_count;
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
op.alter_column(
|
||||
"scribe_messages",
|
||||
"from_identity",
|
||||
existing_type=sa.String(length=63),
|
||||
nullable=False,
|
||||
)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "core-api"
|
||||
version = "1.13.0"
|
||||
version = "1.15.0"
|
||||
description = "Core Code API - Infrastructure management and tools API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
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.
|
||||
(addresses), send, mailbox, history, find, mark, tag/untag, archive/trash
|
||||
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. `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
|
||||
|
||||
@@ -20,6 +25,7 @@ from src.domains.scribe.schemas import (
|
||||
MailboxEntryResponse,
|
||||
MailboxListResponse,
|
||||
MailboxPatchRequest,
|
||||
MailboxView,
|
||||
MessageResponse,
|
||||
MessageSendRequest,
|
||||
)
|
||||
@@ -33,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:
|
||||
@@ -54,6 +67,8 @@ def _entry_response(entry, message) -> MailboxEntryResponse:
|
||||
id=entry.id,
|
||||
address=entry.address,
|
||||
read_at=entry.read_at,
|
||||
archived_at=entry.archived_at,
|
||||
trashed_at=entry.trashed_at,
|
||||
tags=entry.tags,
|
||||
created_at=entry.created_at,
|
||||
message=_message_response(message),
|
||||
@@ -123,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:
|
||||
@@ -171,16 +186,31 @@ class ScribeController(BaseController):
|
||||
summary="The caller identity's mailbox, unread first",
|
||||
)
|
||||
async def get_mailbox(
|
||||
view: MailboxView = Query(
|
||||
"inbox", description="inbox (default, neither archived nor trashed), archive, trash, all"
|
||||
),
|
||||
unread: bool = Query(False),
|
||||
tag: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
before_id: Optional[int] = Query(None),
|
||||
message_id: Optional[int] = Query(
|
||||
None,
|
||||
description="look up the caller's own entry for this message number directly, "
|
||||
"in any view, ignoring unread/tag/before_id (core-api T-9)",
|
||||
),
|
||||
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
|
||||
session,
|
||||
caller,
|
||||
view=view,
|
||||
unread=unread,
|
||||
tag=tag,
|
||||
limit=limit,
|
||||
before_id=before_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -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)
|
||||
@@ -134,6 +139,12 @@ class ScribeMailboxEntry(Base):
|
||||
Integer, ForeignKey("scribe_messages.id"), nullable=False
|
||||
)
|
||||
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Migration 009 (core-api T-13): deletion is per mailbox entry, never
|
||||
# per message (D-66/D-70) — independent nullable timestamps, mirroring
|
||||
# book_records.archived_at/trashed_at. The service layer's view=trash
|
||||
# takes priority over view=archive when both are set.
|
||||
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
trashed_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),
|
||||
|
||||
@@ -3,17 +3,20 @@ 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).
|
||||
`find`, `mark`, `tag`/`untag`, archive/trash a mailbox entry and look one
|
||||
up by message number (core-api T-13/T-9) — 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 typing import List, Literal, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from src.domains.scribe.models import ADDRESS_NAME_PATTERN
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
MailboxView = Literal["inbox", "archive", "trash", "all"]
|
||||
|
||||
|
||||
class AddressRegisterRequest(BaseSchema):
|
||||
"""`register` — the name only; owner (tenant, consumer) comes from the caller."""
|
||||
@@ -46,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
|
||||
@@ -61,6 +67,8 @@ class MailboxEntryResponse(BaseSchema):
|
||||
id: int
|
||||
address: str
|
||||
read_at: Optional[datetime]
|
||||
archived_at: Optional[datetime]
|
||||
trashed_at: Optional[datetime]
|
||||
tags: List[str]
|
||||
created_at: datetime
|
||||
message: MessageResponse
|
||||
@@ -78,8 +86,13 @@ class HistoryResponse(BaseSchema):
|
||||
|
||||
|
||||
class MailboxPatchRequest(BaseSchema):
|
||||
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share one route."""
|
||||
"""`mark` (`read`), `archive`/`unarchive`, `trash`/`restore` and
|
||||
`tag`/`untag` (`add_tags`/`remove_tags`) share one route. `archived`/
|
||||
`trashed` are independent: `true` sets the timestamp (if not already
|
||||
set), `false` clears it (workspace core-api T-13)."""
|
||||
|
||||
read: Optional[bool] = None
|
||||
archived: Optional[bool] = None
|
||||
trashed: Optional[bool] = None
|
||||
add_tags: Optional[List[str]] = None
|
||||
remove_tags: Optional[List[str]] = None
|
||||
|
||||
+110
-17
@@ -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
|
||||
|
||||
@@ -199,19 +235,57 @@ class ScribeService:
|
||||
self,
|
||||
session: AsyncSession,
|
||||
caller: Caller,
|
||||
view: str = "inbox",
|
||||
unread: Optional[bool] = None,
|
||||
tag: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
before_id: Optional[int] = None,
|
||||
message_id: Optional[int] = None,
|
||||
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
|
||||
"""`mailbox` — the caller's identity's entries, unread first."""
|
||||
"""`mailbox` — the caller's identity's entries, unread first.
|
||||
|
||||
`message_id`, when given, is a direct lookup rather than a filtered
|
||||
list: zero or one entry (the unique constraint on
|
||||
(address, message_id) guarantees at most one), in any view — it
|
||||
ignores `view`/`unread`/`tag`/`before_id` entirely, which is the
|
||||
point (core-api T-9): a client that already knows the message
|
||||
number never pages the unread-first/id-descending listing to find
|
||||
it.
|
||||
|
||||
Otherwise `view` selects which of the independent `archived_at`/
|
||||
`trashed_at` states to show: `inbox` (default) is neither,
|
||||
`archive` is archived and not trashed, `trash` is trashed
|
||||
(regardless of archived_at — trashing hides an entry from the
|
||||
archive view too), `all` applies no filter on either column.
|
||||
"""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
await self._require_owned_identity(session, caller, tenant)
|
||||
|
||||
if message_id is not None:
|
||||
lookup_query = select(ScribeMailboxEntry).where(
|
||||
ScribeMailboxEntry.owner_tenant == tenant,
|
||||
ScribeMailboxEntry.address == caller.identity,
|
||||
ScribeMailboxEntry.message_id == message_id,
|
||||
)
|
||||
entries = list((await session.execute(lookup_query)).scalars().all())
|
||||
messages_by_id = await self._messages_for_entries(session, entries)
|
||||
results = [(entry, messages_by_id[entry.message_id]) for entry in entries]
|
||||
return results, len(results)
|
||||
|
||||
conditions = [
|
||||
ScribeMailboxEntry.owner_tenant == tenant,
|
||||
ScribeMailboxEntry.address == caller.identity,
|
||||
]
|
||||
if view == "archive":
|
||||
conditions.append(ScribeMailboxEntry.archived_at.isnot(None))
|
||||
conditions.append(ScribeMailboxEntry.trashed_at.is_(None))
|
||||
elif view == "trash":
|
||||
conditions.append(ScribeMailboxEntry.trashed_at.isnot(None))
|
||||
elif view == "all":
|
||||
pass
|
||||
else: # "inbox" (default)
|
||||
conditions.append(ScribeMailboxEntry.archived_at.is_(None))
|
||||
conditions.append(ScribeMailboxEntry.trashed_at.is_(None))
|
||||
if unread:
|
||||
conditions.append(ScribeMailboxEntry.read_at.is_(None))
|
||||
if tag is not None:
|
||||
@@ -236,7 +310,10 @@ class ScribeService:
|
||||
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."""
|
||||
"""`find` — full text over messages in the caller identity's
|
||||
mailbox, every entry that is not trashed (core-api T-13: trash
|
||||
takes an entry out of `find` the same way it takes it out of
|
||||
`inbox` and `archive`); archived entries still match."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
await self._require_owned_identity(session, caller, tenant)
|
||||
|
||||
@@ -247,6 +324,7 @@ class ScribeService:
|
||||
.where(
|
||||
ScribeMailboxEntry.owner_tenant == tenant,
|
||||
ScribeMailboxEntry.address == caller.identity,
|
||||
ScribeMailboxEntry.trashed_at.is_(None),
|
||||
ScribeMessage.search.op("@@")(tsquery),
|
||||
)
|
||||
)
|
||||
@@ -302,11 +380,16 @@ class ScribeService:
|
||||
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."""
|
||||
"""`mark` (`read`), `archive`/`unarchive`, `trash`/`restore` 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. `archived`/`trashed` are independent timestamps (core-api
|
||||
T-13): `true` sets the one not already set, `false` clears it —
|
||||
trashing does not clear `archived_at`, so restoring a message that
|
||||
was archived before it was trashed returns it to the archive view,
|
||||
not the inbox."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
|
||||
entry = await session.get(ScribeMailboxEntry, entry_id)
|
||||
@@ -318,6 +401,16 @@ class ScribeService:
|
||||
elif data.read is False:
|
||||
entry.read_at = None
|
||||
|
||||
if data.archived is True and entry.archived_at is None:
|
||||
entry.archived_at = datetime.now(timezone.utc)
|
||||
elif data.archived is False:
|
||||
entry.archived_at = None
|
||||
|
||||
if data.trashed is True and entry.trashed_at is None:
|
||||
entry.trashed_at = datetime.now(timezone.utc)
|
||||
elif data.trashed is False:
|
||||
entry.trashed_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]
|
||||
|
||||
@@ -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
|
||||
@@ -618,3 +746,300 @@ class TestTenantRegistryEnforcement:
|
||||
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant=TENANT)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Archive and trash a mailbox entry (core-api T-13 / D-66, D-70): deletion
|
||||
# is per mailbox entry, never per message — the message stays in `history`
|
||||
# and another recipient's entry is untouched throughout.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _send_and_get_entry_id(client, body: str, to: str = "tower-control") -> tuple:
|
||||
"""Register the usual pair if needed by the caller, send one message,
|
||||
and return (message, entry_id) for the recipient's own mailbox entry."""
|
||||
sent = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": [to], "body": body},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
).json()
|
||||
entry_id = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()["entries"][0]["id"]
|
||||
return sent, entry_id
|
||||
|
||||
|
||||
class TestArchiveAndTrash:
|
||||
def test_archive_hides_from_inbox_and_shows_in_archive_view(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
_, entry_id = _send_and_get_entry_id(client, "archive me")
|
||||
|
||||
archived = client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"archived": True},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
assert archived.status_code == 200
|
||||
assert archived.json()["archived_at"] is not None
|
||||
assert archived.json()["trashed_at"] is None
|
||||
|
||||
inbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
assert inbox["total"] == 0
|
||||
|
||||
archive_view = client.get(
|
||||
"/scribe/mailbox",
|
||||
params={"view": "archive"},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert archive_view["total"] == 1
|
||||
assert archive_view["entries"][0]["id"] == entry_id
|
||||
|
||||
def test_unarchive_brings_it_back_to_inbox(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
_, entry_id = _send_and_get_entry_id(client, "archive then unarchive")
|
||||
client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"archived": True},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
|
||||
unarchived = client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"archived": False},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
assert unarchived.json()["archived_at"] is None
|
||||
|
||||
inbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
assert inbox["total"] == 1
|
||||
assert inbox["entries"][0]["id"] == entry_id
|
||||
|
||||
def test_trash_hides_from_inbox_archive_and_find_shows_in_trash(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
_, entry_id = _send_and_get_entry_id(client, "trash-findable-term")
|
||||
|
||||
trashed = client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"trashed": True},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
assert trashed.status_code == 200
|
||||
assert trashed.json()["trashed_at"] is not None
|
||||
|
||||
inbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
assert inbox["total"] == 0
|
||||
|
||||
archive_view = client.get(
|
||||
"/scribe/mailbox",
|
||||
params={"view": "archive"},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert archive_view["total"] == 0
|
||||
|
||||
found = client.get(
|
||||
"/scribe/messages",
|
||||
params={"q": "trash-findable-term"},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert found["total"] == 0
|
||||
|
||||
trash_view = client.get(
|
||||
"/scribe/mailbox",
|
||||
params={"view": "trash"},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert trash_view["total"] == 1
|
||||
assert trash_view["entries"][0]["id"] == entry_id
|
||||
|
||||
def test_restore_brings_it_back_to_inbox(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
_, entry_id = _send_and_get_entry_id(client, "trash then restore")
|
||||
client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"trashed": True},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
|
||||
restored = client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"trashed": False},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
assert restored.json()["trashed_at"] is None
|
||||
|
||||
inbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
assert inbox["total"] == 1
|
||||
assert inbox["entries"][0]["id"] == entry_id
|
||||
|
||||
def test_message_stays_in_history_throughout_archive_and_trash(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
message, entry_id = _send_and_get_entry_id(client, "history survives")
|
||||
|
||||
for payload in ({"archived": True}, {"trashed": True}):
|
||||
client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json=payload,
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
history = client.get(
|
||||
"/scribe/addresses/tower-control/history",
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert [m["id"] for m in history["messages"]] == [message["id"]]
|
||||
|
||||
def test_trashing_one_recipients_entry_leaves_the_others_untouched(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
register(client, "jeroen-2", "office-claude")
|
||||
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control", "jeroen-2"], "body": "broadcast"},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
)
|
||||
tower_entry_id = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()["entries"][0]["id"]
|
||||
|
||||
client.patch(
|
||||
f"/scribe/mailbox/{tower_entry_id}",
|
||||
json={"trashed": True},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
|
||||
other_inbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("jeroen-2", "office-claude")
|
||||
).json()
|
||||
assert other_inbox["total"] == 1
|
||||
assert other_inbox["entries"][0]["trashed_at"] is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Look an entry up by message number (core-api T-9): zero or one, in any
|
||||
# view, no paging required.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestMailboxMessageIdLookup:
|
||||
def test_message_id_finds_the_entry_in_any_view(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
message, entry_id = _send_and_get_entry_id(client, "locate me")
|
||||
client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"trashed": True},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
|
||||
found = client.get(
|
||||
"/scribe/mailbox",
|
||||
params={"message_id": message["id"]},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert found["total"] == 1
|
||||
assert found["entries"][0]["id"] == entry_id
|
||||
assert found["entries"][0]["trashed_at"] is not None
|
||||
|
||||
def test_message_id_answers_nothing_for_a_message_not_in_their_mailbox(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
sent = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "not jeroen's"},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
).json()
|
||||
|
||||
found = client.get(
|
||||
"/scribe/mailbox",
|
||||
params={"message_id": sent["id"]},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
).json()
|
||||
assert found["total"] == 0
|
||||
assert found["entries"] == []
|
||||
|
||||
def test_message_id_answers_nothing_across_tenants(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": "tenant-scoped"},
|
||||
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
|
||||
).json()
|
||||
|
||||
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
|
||||
found = client.get(
|
||||
"/scribe/mailbox",
|
||||
params={"message_id": sent["id"]},
|
||||
headers=office_headers("tower-control", "office-gemini", OTHER_TENANT),
|
||||
).json()
|
||||
assert found["total"] == 0
|
||||
|
||||
def test_message_id_finds_a_read_entry_past_the_first_page_in_one_call(self, client):
|
||||
"""dossier verification #4: today's listing sorts unread-first,
|
||||
then by id descending, behind a single before_id cursor. Seed one
|
||||
old entry that stays unread (so it sorts first regardless of its
|
||||
low id) ahead of 51 read ones (one more than the default page size
|
||||
of 50) — the oldest read entry (lowest id among them) sorts last,
|
||||
past the first page. `message_id` answers it in one call, with no
|
||||
`before_id` paging."""
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
|
||||
old_unread = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "old and unread"},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
).json()
|
||||
|
||||
read_message_ids = []
|
||||
for i in range(51):
|
||||
sent = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": f"read {i}"},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
).json()
|
||||
read_message_ids.append(sent["id"])
|
||||
|
||||
all_entries = client.get(
|
||||
"/scribe/mailbox",
|
||||
params={"view": "all", "limit": 200},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()["entries"]
|
||||
for entry in all_entries:
|
||||
if entry["message"]["id"] != old_unread["id"]:
|
||||
client.patch(
|
||||
f"/scribe/mailbox/{entry['id']}",
|
||||
json={"read": True},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
|
||||
# The oldest (lowest-id) read message: last among the reads when
|
||||
# sorted id-descending, so page 1 (the default limit of 50) never
|
||||
# reaches it — plain listing would need a second before_id call.
|
||||
target_message_id = read_message_ids[0]
|
||||
|
||||
page_one = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
assert target_message_id not in {e["message"]["id"] for e in page_one["entries"]}
|
||||
|
||||
found = client.get(
|
||||
"/scribe/mailbox",
|
||||
params={"message_id": target_message_id},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert found["total"] == 1
|
||||
assert found["entries"][0]["message"]["id"] == target_message_id
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for Scribe migration 006.
|
||||
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,74 @@ 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)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = :table"
|
||||
),
|
||||
{"table": table},
|
||||
)
|
||||
return {row[0] for row in result.all()}
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestMigration006RoundTrip:
|
||||
"""Migration 006 must upgrade and downgrade cleanly (dossier verification #1)."""
|
||||
|
||||
@@ -58,3 +126,87 @@ class TestMigration006RoundTrip:
|
||||
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")
|
||||
|
||||
|
||||
class TestMigration009RoundTrip:
|
||||
"""Migration 009 (core-api T-13) must upgrade and downgrade cleanly —
|
||||
dossier verification #2: 008 -> 009 -> 008 -> 009."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_adds_archived_and_trashed_at(self, test_database_url):
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
columns = await _columns(test_database_url, "scribe_mailbox_entries")
|
||||
assert {"archived_at", "trashed_at"} <= columns
|
||||
|
||||
@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", "008")
|
||||
assert down.returncode == 0, down.stderr
|
||||
columns_at_008 = await _columns(test_database_url, "scribe_mailbox_entries")
|
||||
assert "archived_at" not in columns_at_008
|
||||
assert "trashed_at" not in columns_at_008
|
||||
# The table itself, created by 006, must survive a downgrade of
|
||||
# 009 — proves 009 only adds/drops its own two columns.
|
||||
assert await _table_exists(test_database_url, "scribe_mailbox_entries")
|
||||
|
||||
# Leave the schema at head (009 is head) for the rest of the suite.
|
||||
up = run_alembic(test_database_url, "upgrade", "head")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user