Compare commits
@@ -91,3 +91,131 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
|||||||
|
|
||||||
**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.', NULL, '2026-10-19 14:06:27', '2026-10-19 14:06:27.325', '2026-10-19 14:06:27.325', NULL, '0e233f93ee1e85162d05109749d33032', 2) ON CONFLICT(hash) DO NOTHING;
|
**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.', NULL, '2026-10-19 14:06:27', '2026-10-19 14:06:27.325', '2026-10-19 14:06:27.325', NULL, '0e233f93ee1e85162d05109749d33032', 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 ('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 ('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;
|
||||||
|
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 ('06GNBZZR0RQKSAJ5FKF4X540KC', 'description', '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.', '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.
|
||||||
|
|
||||||
|
Live in core-api 1.15.0 (2026-10-19); production migrated to 010. Round core-api-T-15-001: 577 passed; three mutation checks each failed only their own cases.', NULL, '2026-10-19 21:40:25', '2026-10-19 21:40:25.352', '2026-10-19 21:40:25.352', NULL, 'edac5067cff35bc3d688472893b3a85a', 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 ('06GNBZZR0RQKSAJ5FKF4X540KC', 'status', 'backlog', 'done', NULL, '2026-10-19 21:40:25', '2026-10-19 21:40:25.488', '2026-10-19 21:40:25.488', NULL, '133d4dbe55b577a764096f1f3b0756c0', 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 ('06GN6XMYNQF0VP25QMY7YTB41W', 'description', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||||
|
|
||||||
|
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||||
|
|
||||||
|
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
|
||||||
|
|
||||||
|
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.', NULL, '2026-10-19 21:40:25', '2026-10-19 21:40:25.647', '2026-10-19 21:40:25.647', NULL, '41fe70178658d2e1e299492755e0d295', 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 ('06GNBXC7H3JBESKFS20BV71BJG', 'description', '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.', '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.
|
||||||
|
|
||||||
|
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).', NULL, '2026-10-19 21:40:25', '2026-10-19 21:40:25.820', '2026-10-19 21:40:25.820', NULL, '62065aa1d8fd7ad21e9a7abd2f70f0c1', 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 ('06GNCC8BZT7RYQC16XK1MXBBRM', 'description', 'For the Office''s polling (tatlock-office T-11): GET /scribe/mailbox takes since_message_id=N — only the caller''s entries whose message number is above N, combined with view/unread/tag as today — and total counts what matched, so unread=true&limit=1 is a one-row unread count. Waits for round core-api-T-3-001, which is in this repo now.', 'For the Office''s polling (tatlock-office T-11): GET /scribe/mailbox takes since_message_id=N — only the caller''s entries whose message number is above N, combined with view/unread/tag as today — and total counts what matched, so unread=true&limit=1 is a one-row unread count. Waits for round core-api-T-3-001, which is in this repo now.
|
||||||
|
|
||||||
|
**Also: the newest message number.** The mailbox response carries the caller''s newest message number across its whole mailbox, whatever the filters (`newest_message_id`). The Office''s `glance` needs it: the unread-first sort makes `limit=1` return an older unread message when a newer one is already read (found in round tatlock-office-T-11-001).', NULL, '2026-10-19 22:25:51', '2026-10-19 22:25:51.654', '2026-10-19 22:25:51.654', NULL, '257d4c93e0c8ff4691ec032c7dd7f0f8', 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 ('06GN6XMYNQF0VP25QMY7YTB41W', 'description', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||||
|
|
||||||
|
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
|
||||||
|
|
||||||
|
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||||
|
|
||||||
|
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
|
||||||
|
|
||||||
|
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.
|
||||||
|
|
||||||
|
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', NULL, '2026-10-19 22:35:10', '2026-10-19 22:35:10.863', '2026-10-19 22:35:10.863', NULL, 'cd699cb99d78be586d2808f37152f056', 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 ('06GN6XMYNQF0VP25QMY7YTB41W', 'status', 'backlog', 'done', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.000', '2026-10-19 22:35:11.000', NULL, 'd75c90d9b2454d4c5dcf0932aa36aea0', 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 ('06GNBXC7H3JBESKFS20BV71BJG', 'description', '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.
|
||||||
|
|
||||||
|
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).', '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.
|
||||||
|
|
||||||
|
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).
|
||||||
|
|
||||||
|
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.145', '2026-10-19 22:35:11.145', NULL, '6dd54bb01ed3a435150837ad5d1fa1be', 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 ('06GNBXC7H3JBESKFS20BV71BJG', 'status', 'backlog', 'done', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.296', '2026-10-19 22:35:11.296', NULL, '779820a64d072b6c946f9b07e079bb00', 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 ('06GNBHXYDJEEDQEADCP3JTR7BR', 'description', '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.', '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.
|
||||||
|
|
||||||
|
Done in round core-api-T-3-001 (2f53722): the fixture''s repr masks the password. **Residual, out of scope:** asyncpg''s own InvalidPasswordError traceback prints ConnectionParameters with the password from inside the library — a separate, third-party surface.', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.418', '2026-10-19 22:35:11.418', NULL, '93ca44bcf4fa3cb2612df713fbaf7162', 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 ('06GNBHXYDJEEDQEADCP3JTR7BR', 'status', 'backlog', 'done', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.544', '2026-10-19 22:35:11.544', NULL, 'f03cf5fb39db181595aa47948e980228', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
|||||||
@@ -6,3 +6,11 @@ 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 ('06GN8NBWVQ53ZX7WMZNE5GJJP4', 'T-6', '2026-10-19 13:24:11.102', '2026-10-19 13:24:11.102', NULL, 'f6351f2d6190ae5fbd9e55e6eadc04e5', 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 ('06GN8NBWVQ53ZX7WMZNE5GJJP4', 'T-6', '2026-10-19 13:24:11.102', '2026-10-19 13:24:11.102', NULL, 'f6351f2d6190ae5fbd9e55e6eadc04e5', 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 ('06GN8Z1FF1QDGR4QE4405AP8GC', 'T-7', '2026-10-19 14:06:27.193', '2026-10-19 14:06:27.193', NULL, '09267e8234ec21c6d75767d7cd21bb51', 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 ('06GN8Z1FF1QDGR4QE4405AP8GC', 'T-7', '2026-10-19 14:06:27.193', '2026-10-19 14:06:27.193', NULL, '09267e8234ec21c6d75767d7cd21bb51', 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 ('06GN99CB552PSF82EE2P8PJZP0', 'T-8', '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, 'd8c7f6122652cb1c3e448f1adedd54ab', 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 ('06GN99CB552PSF82EE2P8PJZP0', 'T-8', '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, 'd8c7f6122652cb1c3e448f1adedd54ab', 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 ('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;
|
||||||
|
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNCC8BZT7RYQC16XK1MXBBRM', 'T-16', '2026-10-19 22:03:37.343', '2026-10-19 22:03:37.343', NULL, '092ac223950852535456b31c45a8ae04', 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;
|
||||||
|
|||||||
@@ -94,3 +94,168 @@ Today a tenant is an unchecked string: the Office header or a JWT''s `tatlock_us
|
|||||||
|
|
||||||
**Not in v1:** write routes for provisioning (T-195 on the workspace board adds them, or an operator verb); per-consumer verb classes (workspace T-184 — a column on `tenant_consumers` when it lands).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, '756a467f5dc4a84c7ee9080ef21ca5fb', 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;
|
**Not in v1:** write routes for provisioning (T-195 on the workspace board adds them, or an operator verb); per-consumer verb classes (workspace T-184 — a column on `tenant_consumers` when it lands).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, '756a467f5dc4a84c7ee9080ef21ca5fb', 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 ('06GN8Z1FF1QDGR4QE4405AP8GC', 'task', NULL, 'The Book''s final schema and tenant boundary, before 005''s first release', 'The Book''s final schema and its tenant boundary, before core-api''s first release applies migration 005 to production. Split from T-2 (whose remaining verbs stay there). Amend 005 — never run in production — rather than add a migration: per-tenant short outward numbers ("Task 12", workspace D-67), an owner tenant on every record (D-73), and every Book route filtered by it, so the routes are safe to expose the moment they deploy.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 14:06:27.192', '2026-10-19 14:51:47.496', NULL, '8797eb16319aa33c8317b0a1f0defc3d', 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 ('06GN8Z1FF1QDGR4QE4405AP8GC', 'task', NULL, 'The Book''s final schema and tenant boundary, before 005''s first release', 'The Book''s final schema and its tenant boundary, before core-api''s first release applies migration 005 to production. Split from T-2 (whose remaining verbs stay there). Amend 005 — never run in production — rather than add a migration: per-tenant short outward numbers ("Task 12", workspace D-67), an owner tenant on every record (D-73), and every Book route filtered by it, so the routes are safe to expose the moment they deploy.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 14:06:27.192', '2026-10-19 14:51:47.496', NULL, '8797eb16319aa33c8317b0a1f0defc3d', 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 ('06GN6KV13SSS2KZP10SKTT903C', 'task', NULL, 'Caller auth, locked housekeeping routes, and the Book domain', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||||
|
|
||||||
|
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||||
|
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.
|
||||||
|
|
||||||
|
**Code done 2026-10-19**, commits `04ec54b`…`63feb88` (round core-api-T-1-001): `make test` 381 → 430 passed; migration 005 round-trips on `core_api_test`; production `core_api` still at 004; mutation-checked (key compare, the housekeeping lock, the append-only trigger). Follow-ups: T-2 (remaining verbs, short ids, scope tags — amends 005 before its first deploy), T-3 (alembic target). Held for the milestone.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 08:37:53.566', '2026-10-19 15:48:03.950', NULL, '4f256fd107a235ce29e881208eca9fbc', 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 ('06GN99CB552PSF82EE2P8PJZP0', 'story', NULL, 'The tenant registry: tenants, consumer grants, and one enforcement point', 'The tenant registry: one table that says which tenants exist, which consumers may act in each, and whether a tenant is active — the anchor every service''s tenancy conforms to (workspace D-73).
|
||||||
|
|
||||||
|
Today a tenant is an unchecked string: the Office header or a JWT''s `tatlock_user` claim is trusted as-is, and the Book and the Scribe file rows under whatever value arrives. A typo''d claim opens a fresh, empty namespace instead of being refused.
|
||||||
|
|
||||||
|
**Tables (migration 007):**
|
||||||
|
- `tenants` — `id` (the slug, the value of `tatlock_user`; same pattern `_require_office_tenant` accepts), `display_name`, `kind` (`household` | `person` | `test`), `status` (`active` | `suspended`), timestamps. Seeded: `jpmschweitzer` (household), `llm_tester` (test).
|
||||||
|
- `tenant_consumers` — a grant: consumer X may act in tenant Y. PK `(tenant_id, consumer)`. Seeded: `office-claude` in both seeded tenants.
|
||||||
|
- Foreign keys from every `owner_tenant` column in the Book and the Scribe to `tenants.id`.
|
||||||
|
|
||||||
|
**Enforcement:** every tenant-scoped operation goes through one helper. No tenant → 400 `tenant_required`. Tenant unknown → 403 `tenant_unknown`. Tenant suspended → 403 `tenant_suspended`. Office caller whose consumer holds no grant in that tenant → 403 `consumer_not_granted`.
|
||||||
|
|
||||||
|
**Read API, for other services to conform to:** `GET /tenants/me` (the caller''s own tenant and its consumer''s grant); `GET /tenants` (service callers without a tenant see all; a tenant-bearing caller sees only its own — the hard boundary).
|
||||||
|
|
||||||
|
**Not in v1:** write routes for provisioning (T-195 on the workspace board adds them, or an operator verb); per-consumer verb classes (workspace T-184 — a column on `tenant_consumers` when it lands).', 'done', 'high', NULL, NULL, NULL, '2026-10-19 14:51:37.641', '2026-10-19 15:48:04.079', NULL, '4adf867d5e7e4424e6a2e97b53f958d1', 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.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 17:07:53.702', '2026-10-19 17:07:53.702', NULL, 'c6599dbbb04302146398615a0caf9e2d', 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 ('06GNA9K6GSTB09D1SWV649DZ3M', 'task', NULL, 'Scribe messages number per tenant, like the Book', 'Scribe message numbers are global: the playground tenant''s first message was "message 2" because message 1 lives in the household''s tenant. That lets one tenant count another''s mail — a small leak through the hard boundary (workspace D-73), and exactly why the Book numbers per tenant (T-7). Give messages a per-tenant `number` the same way (`owner_tenant`, a per-tenant counter taken FOR UPDATE), answer every route with it, and accept it wherever a message id is taken today. A migration on live data: existing messages get numbers in id order per tenant.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 17:12:22.406', '2026-10-19 17:12:22.406', NULL, '3d3b298bf83b044360d3141137a259a6', 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).', '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;
|
||||||
|
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.
|
||||||
|
|
||||||
|
Live in core-api 1.15.0 (2026-10-19); production migrated to 010. Round core-api-T-15-001: 577 passed; three mutation checks each failed only their own cases.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 21:10:00.967', '2026-10-19 21:40:25.352', NULL, '55c6b195649162421fc352c2b50a9fe9', 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.
|
||||||
|
|
||||||
|
Live in core-api 1.15.0 (2026-10-19); production migrated to 010. Round core-api-T-15-001: 577 passed; three mutation checks each failed only their own cases.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 21:10:00.967', '2026-10-19 21:40:25.487', NULL, 'f341424841045769b2232e09f3c06326', 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 ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||||
|
|
||||||
|
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
|
||||||
|
|
||||||
|
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 21:40:25.647', NULL, 'e5e46c76d533149faf0b37fe13044006', 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.
|
||||||
|
|
||||||
|
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).', 'backlog', 'low', NULL, NULL, NULL, '2026-10-19 20:58:36.809', '2026-10-19 21:40:25.820', NULL, '4501f0022158bcc0083cd2309d79c115', 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 ('06GNCC8BZT7RYQC16XK1MXBBRM', 'task', NULL, 'The mailbox answers only messages after N, with a count', 'For the Office''s polling (tatlock-office T-11): GET /scribe/mailbox takes since_message_id=N — only the caller''s entries whose message number is above N, combined with view/unread/tag as today — and total counts what matched, so unread=true&limit=1 is a one-row unread count. Waits for round core-api-T-3-001, which is in this repo now.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 22:03:37.343', '2026-10-19 22:03:37.343', NULL, 'fd4a1b4c6ed521ffaaa246665f7e45ca', 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 ('06GNCC8BZT7RYQC16XK1MXBBRM', 'task', NULL, 'The mailbox answers only messages after N, with a count', 'For the Office''s polling (tatlock-office T-11): GET /scribe/mailbox takes since_message_id=N — only the caller''s entries whose message number is above N, combined with view/unread/tag as today — and total counts what matched, so unread=true&limit=1 is a one-row unread count. Waits for round core-api-T-3-001, which is in this repo now.
|
||||||
|
|
||||||
|
**Also: the newest message number.** The mailbox response carries the caller''s newest message number across its whole mailbox, whatever the filters (`newest_message_id`). The Office''s `glance` needs it: the unread-first sort makes `limit=1` return an older unread message when a newer one is already read (found in round tatlock-office-T-11-001).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 22:03:37.343', '2026-10-19 22:25:51.653', NULL, '8f6daa3a748e4b26d6c7ce40fc9ee8a0', 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 ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||||
|
|
||||||
|
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
|
||||||
|
|
||||||
|
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.
|
||||||
|
|
||||||
|
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 22:35:10.863', NULL, '1afa273c4e58785108159efc17bf7aa7', 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 ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||||
|
|
||||||
|
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||||
|
|
||||||
|
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||||
|
|
||||||
|
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
|
||||||
|
|
||||||
|
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.
|
||||||
|
|
||||||
|
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', 'done', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 22:35:10.999', NULL, '6a9a2751c2c65907dac8dd105fec266b', 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.
|
||||||
|
|
||||||
|
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).
|
||||||
|
|
||||||
|
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', 'backlog', 'low', NULL, NULL, NULL, '2026-10-19 20:58:36.809', '2026-10-19 22:35:11.145', NULL, '0badbf207b4a0001fa2d45906cd8bc6f', 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.
|
||||||
|
|
||||||
|
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).
|
||||||
|
|
||||||
|
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', 'done', 'low', NULL, NULL, NULL, '2026-10-19 20:58:36.809', '2026-10-19 22:35:11.296', NULL, '88884cb0b3db1fd86f7ef51ff23169b0', 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.
|
||||||
|
|
||||||
|
Done in round core-api-T-3-001 (2f53722): the fixture''s repr masks the password. **Residual, out of scope:** asyncpg''s own InvalidPasswordError traceback prints ConnectionParameters with the password from inside the library — a separate, third-party surface.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 20:08:36.204', '2026-10-19 22:35:11.418', NULL, '42290765032587681dfcbf22269c6cd8', 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.
|
||||||
|
|
||||||
|
Done in round core-api-T-3-001 (2f53722): the fixture''s repr masks the password. **Residual, out of scope:** asyncpg''s own InvalidPasswordError traceback prints ConnectionParameters with the password from inside the library — a separate, third-party surface.', 'done', 'medium', NULL, NULL, NULL, '2026-10-19 20:08:36.204', '2026-10-19 22:35:11.544', NULL, '43971c390f52501fa6345b15d96ccba0', 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,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.16.0] - 2026-10-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`GET /scribe/mailbox` takes `since_message_id`, and answers with `newest_message_id`**
|
||||||
|
(core-api T-16) — the Office's `glance` call needs "has anything changed since I last
|
||||||
|
looked?" with counts. `since_message_id=N` keeps only entries whose message number is
|
||||||
|
above `N`, combined with `view`/`unread`/`tag` as today (`message_id`, which already
|
||||||
|
bypasses the other filters, keeps bypassing `since_message_id` too). `total` already
|
||||||
|
counted every match independent of `limit`; that is unchanged, `since_message_id` is
|
||||||
|
just one more filter it counts against. `newest_message_id` is the caller's newest
|
||||||
|
message number across its whole mailbox — every view, regardless of any filter applied
|
||||||
|
— null when the mailbox is empty; it exists because the unread-first sort can return an
|
||||||
|
older unread entry on a `limit=1` page while a newer message is already read.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Alembic refuses to run without a named migration target** (core-api T-3) —
|
||||||
|
`alembic/env.py` exits non-zero before building any connection unless `-x target=test`
|
||||||
|
or `-x target=production` is given; nothing defaults, so the most natural command in
|
||||||
|
the repo, a bare `alembic current`, can no longer reach production by accident.
|
||||||
|
`make migrate-test`, `make migrate-production` and `make dev-test-server` are the three
|
||||||
|
documented ways to reach either database; none of them print a password or a
|
||||||
|
credential-bearing URL.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Migration round-trip tests leave `core_api_test` at head** (core-api T-14) — every
|
||||||
|
round-trip test in `tests/test_book_migration.py`, `tests/test_scribe_migration.py` and
|
||||||
|
`tests/test_tenants_migration.py` now ends on `upgrade head` rather than its own
|
||||||
|
revision, so a full suite run leaves the test database at head instead of one or two
|
||||||
|
migrations behind it.
|
||||||
|
- **A failing database test no longer prints the test database password** (core-api
|
||||||
|
T-12) — the `test_database_url` fixture now returns a `MaskedURL`: identical to a
|
||||||
|
plain string for every functional use, but its `repr()` — which pytest prints for a
|
||||||
|
failing test's fixture arguments, without needing `-v` — masks the password.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- **A default address on each consumer grant** (`tenant_consumers.default_address`,
|
||||||
|
migration 008, core-api T-11) — the Office's fallback when a call names no
|
||||||
|
address and holds no session binding, since claude.ai's connector keeps no
|
||||||
|
MCP session between tool calls. A composite foreign key
|
||||||
|
`(tenant_id, default_address)` -> `scribe_addresses(owner_tenant, name)`
|
||||||
|
ensures a default always names a real address in the same tenant.
|
||||||
|
`jpmschweitzer`/`office-claude` defaults to `claude-ai` where that address
|
||||||
|
already exists; every other grant stays null. `GET /tenants/me` answers it
|
||||||
|
for the caller's own consumer grant, or null for a user-kind caller or a
|
||||||
|
grant without one; `GET /tenants` is unchanged. No write route this round —
|
||||||
|
provisioning is later.
|
||||||
|
|
||||||
## [1.12.0] - 2026-10-19
|
## [1.12.0] - 2026-10-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -83,6 +83,24 @@ live under `src/domains/<name>/models.py` and must be imported in `alembic/env.p
|
|||||||
register with `Base.metadata` — check that file when adding a new model or `alembic
|
register with `Base.metadata` — check that file when adding a new model or `alembic
|
||||||
revision --autogenerate` will silently miss it.
|
revision --autogenerate` will silently miss it.
|
||||||
|
|
||||||
|
**A bare `alembic` refuses — it names no target (core-api T-3).** `alembic/env.py`
|
||||||
|
exits non-zero before building any connection unless told explicitly which database:
|
||||||
|
`-x target=test` (the database `TEST_DATABASE_URL` names) or `-x target=production`
|
||||||
|
(today's settings-derived database — real production). Nothing defaults, because a bare
|
||||||
|
`.venv/bin/alembic current` once reached production by accident, read-only but still
|
||||||
|
unintended. Three `make` targets are the only documented way to reach either database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make migrate-test # upgrade core_api_test to head
|
||||||
|
make migrate-production # the one documented way to migrate production — says what
|
||||||
|
# it's about to do, then alembic -x target=production upgrade head
|
||||||
|
make dev-test-server # uvicorn on :8777 against core_api_test; refuses to start
|
||||||
|
# if TEST_DATABASE_URL resolves to any other database name
|
||||||
|
```
|
||||||
|
|
||||||
|
None of the three ever print a password or a credential-bearing URL — only the
|
||||||
|
database name, and (for `migrate-production`) an announcement before it runs.
|
||||||
|
|
||||||
## Working here
|
## Working here
|
||||||
|
|
||||||
**Plan, act, reflect.** Outline which files you will touch and the side effects before
|
**Plan, act, reflect.** Outline which files you will touch and the side effects before
|
||||||
@@ -93,6 +111,11 @@ does the new behaviour have a test?
|
|||||||
code changes (not on `requirements.txt` changes; restart the container/script after
|
code changes (not on `requirements.txt` changes; restart the container/script after
|
||||||
adding a dependency). Deploy only when a feature is complete and tested.
|
adding a dependency). Deploy only when a feature is complete and tested.
|
||||||
|
|
||||||
|
**The full suite takes about 200 seconds** — longer than a two-minute command timeout, and a
|
||||||
|
run killed mid-suite can leave `core_api_test` between migrations. Give `make test` at least
|
||||||
|
five minutes, and confirm the test database is at head (`.venv/bin/alembic -x target=test
|
||||||
|
current`) after an interrupted run.
|
||||||
|
|
||||||
Run tests through the venv explicitly, to avoid environment mismatch:
|
Run tests through the venv explicitly, to avoid environment mismatch:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -38,6 +38,35 @@ test: ## Run the test suite
|
|||||||
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
|
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
|
||||||
$(VENV)/bin/python -m pytest tests/
|
$(VENV)/bin/python -m pytest tests/
|
||||||
|
|
||||||
|
# Alembic refuses to run with no named target, or any other one (core-api
|
||||||
|
# T-3) — `alembic/env.py` exits before connecting rather than falling back
|
||||||
|
# to production. These three targets are the only documented way to reach
|
||||||
|
# either database.
|
||||||
|
|
||||||
|
.PHONY: migrate-test
|
||||||
|
migrate-test: ## Upgrade core_api_test (TEST_DATABASE_URL) to head
|
||||||
|
@test -x $(VENV)/bin/alembic || { echo "FAIL — no venv; run: make setup"; exit 69; }
|
||||||
|
$(VENV)/bin/alembic -x target=test upgrade head
|
||||||
|
|
||||||
|
.PHONY: migrate-production
|
||||||
|
migrate-production: ## The one documented way to migrate production: alembic -x target=production upgrade head
|
||||||
|
@test -x $(VENV)/bin/alembic || { echo "FAIL — no venv; run: make setup"; exit 69; }
|
||||||
|
@echo "migrate-production: about to run 'alembic -x target=production upgrade head' against the production database"
|
||||||
|
$(VENV)/bin/alembic -x target=production upgrade head
|
||||||
|
|
||||||
|
.PHONY: dev-test-server
|
||||||
|
dev-test-server: ## uvicorn on :8777 against core_api_test only (refuses on any other resolved database; never prints credentials)
|
||||||
|
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
|
||||||
|
@url="$$TEST_DATABASE_URL"; \
|
||||||
|
if [ -z "$$url" ]; then url=$$(grep -m1 '^TEST_DATABASE_URL=' .env 2>/dev/null | cut -d= -f2-); fi; \
|
||||||
|
if [ -z "$$url" ]; then echo "FAIL — TEST_DATABASE_URL is not set (environment or .env)"; exit 69; fi; \
|
||||||
|
rest=$${url#postgresql://}; creds=$${rest%%@*}; hostdb=$${rest#*@}; \
|
||||||
|
puser=$${creds%%:*}; ppass=$${creds#*:}; phost=$${hostdb%%/*}; pdb=$${hostdb#*/}; \
|
||||||
|
if [ "$$pdb" != "core_api_test" ]; then echo "FAIL — resolved database is '$$pdb', not core_api_test; refusing to start"; exit 1; fi; \
|
||||||
|
echo "dev-test-server: database=$$pdb"; \
|
||||||
|
POSTGRES_HOST="$$phost" POSTGRES_USER="$$puser" POSTGRES_PASSWORD="$$ppass" POSTGRES_DATABASE="$$pdb" \
|
||||||
|
$(VENV)/bin/python -m uvicorn src.main:app --reload --host 0.0.0.0 --port 8777
|
||||||
|
|
||||||
# No `lint` target, deliberately. This repo configures no linter — no ruff or
|
# No `lint` target, deliberately. This repo configures no linter — no ruff or
|
||||||
# flake8 config, and neither in requirements. Per D-27 the name is reserved for
|
# flake8 config, and neither in requirements. Per D-27 the name is reserved for
|
||||||
# repos that lint; an empty target here would report clean for something never
|
# repos that lint; an empty target here would report clean for something never
|
||||||
|
|||||||
+75
-8
@@ -2,18 +2,30 @@
|
|||||||
Alembic Environment Configuration
|
Alembic Environment Configuration
|
||||||
|
|
||||||
Async migration environment for SQLAlchemy 2.0 with asyncpg.
|
Async migration environment for SQLAlchemy 2.0 with asyncpg.
|
||||||
|
|
||||||
|
Alembic refuses to run without an explicit target (core-api T-3): a bare
|
||||||
|
`alembic current` used to fall back to `get_settings().database_url`, which
|
||||||
|
is composed from `.env`'s production `POSTGRES_*` values — the most natural
|
||||||
|
command in the repo was quietly talking to production. `-x target=test`
|
||||||
|
names the database `TEST_DATABASE_URL` points at; `-x target=production`
|
||||||
|
names today's settings-derived database, deliberately. Nothing else
|
||||||
|
defaults, and the check runs — and can exit — before any engine is built.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
from sqlalchemy.engine import Connection
|
from sqlalchemy.engine import Connection, make_url
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
# Import our models and config
|
# Import our models (config is imported lazily, inside _production_url, so
|
||||||
from src.shared.config import get_settings
|
# that resolving a bad or missing target never has to construct Settings).
|
||||||
from src.shared.database import Base
|
from src.shared.database import Base
|
||||||
|
|
||||||
# Import all models to ensure they're registered with Base.metadata
|
# Import all models to ensure they're registered with Base.metadata
|
||||||
@@ -27,6 +39,8 @@ from src.domains.scribe.models import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
from src.domains.tenants.models import Tenant, TenantConsumer # noqa: F401
|
from src.domains.tenants.models import Tenant, TenantConsumer # noqa: F401
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# Alembic Config object
|
# Alembic Config object
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
@@ -37,11 +51,64 @@ if config.config_file_name is not None:
|
|||||||
# Model metadata for autogenerate support
|
# Model metadata for autogenerate support
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
# Get database URL from our settings
|
|
||||||
settings = get_settings()
|
def _test_url() -> str:
|
||||||
db_url = settings.database_url
|
"""TEST_DATABASE_URL from the environment, else the repo's own `.env` —
|
||||||
if db_url.startswith("postgresql://"):
|
read that one variable only; the file is never loaded or sourced whole."""
|
||||||
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
value = os.environ.get("TEST_DATABASE_URL")
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
env_path = REPO_ROOT / ".env"
|
||||||
|
if env_path.is_file():
|
||||||
|
with open(env_path) as f:
|
||||||
|
for line in f:
|
||||||
|
match = re.match(r"^TEST_DATABASE_URL=(.*)$", line.rstrip("\n"))
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip().strip("'\"")
|
||||||
|
|
||||||
|
raise SystemExit(
|
||||||
|
"alembic: -x target=test needs TEST_DATABASE_URL (environment, or "
|
||||||
|
"the repo's .env) — none found."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _production_url() -> str:
|
||||||
|
"""Today's settings-derived database — deliberately, only when asked
|
||||||
|
for by name."""
|
||||||
|
from src.shared.config import get_settings
|
||||||
|
|
||||||
|
return get_settings().database_url
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target_url() -> str:
|
||||||
|
"""-x target=test|production names the database to migrate. No target,
|
||||||
|
or anything else, exits non-zero here — before any engine is built."""
|
||||||
|
x_args = context.get_x_argument(as_dictionary=True)
|
||||||
|
target = x_args.get("target")
|
||||||
|
|
||||||
|
if target == "test":
|
||||||
|
url = _test_url()
|
||||||
|
elif target == "production":
|
||||||
|
url = _production_url()
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
"alembic: no migration target named. Pass one of:\n"
|
||||||
|
" -x target=test (the database TEST_DATABASE_URL names)\n"
|
||||||
|
" -x target=production (today's settings-derived database)\n"
|
||||||
|
f"Got -x target={target!r}. Nothing defaults."
|
||||||
|
)
|
||||||
|
|
||||||
|
if url.startswith("postgresql://"):
|
||||||
|
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
db_url = _resolve_target_url()
|
||||||
|
|
||||||
|
# Name the target before migrating anything — never a host's credentials,
|
||||||
|
# never the URL itself.
|
||||||
|
print(f"alembic target: database={make_url(db_url).database!r}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Add a default address to each consumer grant (core-api T-11)
|
||||||
|
|
||||||
|
Revision ID: 008
|
||||||
|
Revises: 007
|
||||||
|
Create Date: 2026-10-19
|
||||||
|
|
||||||
|
claude.ai's connector keeps no MCP session between tool calls, so the
|
||||||
|
Office cannot remember which address a conversation chose. Each consumer
|
||||||
|
grant now carries `default_address`: the Office uses it when a call names
|
||||||
|
none and has no session binding (tatlock-office T-5 reads it).
|
||||||
|
|
||||||
|
`tenant_consumers.default_address` is nullable, and a composite foreign
|
||||||
|
key `(tenant_id, default_address)` -> `scribe_addresses(owner_tenant,
|
||||||
|
name)` ensures a default always names a real address in the same tenant.
|
||||||
|
Postgres's default `MATCH SIMPLE` means a NULL `default_address` never
|
||||||
|
triggers the check, so an ungranted default is simply absent rather than
|
||||||
|
a constraint violation waiting to happen.
|
||||||
|
|
||||||
|
Data step: `jpmschweitzer`/`office-claude` defaults to `claude-ai` only if
|
||||||
|
that address already exists at upgrade time - true in production, false
|
||||||
|
in `core_api_test` (no scribe_addresses are seeded there), so the `UPDATE
|
||||||
|
... WHERE EXISTS` must be - and is - a no-op rather than a failure when
|
||||||
|
the address is absent. `llm_tester`/`office-claude` is not touched and
|
||||||
|
stays null.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "008"
|
||||||
|
down_revision: Union[str, None] = "007"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add the column, bind it to scribe_addresses, then seed the one
|
||||||
|
production default the dossier specifies."""
|
||||||
|
|
||||||
|
op.add_column(
|
||||||
|
"tenant_consumers",
|
||||||
|
sa.Column("default_address", sa.String(length=63), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_tenant_consumers_default_address",
|
||||||
|
"tenant_consumers",
|
||||||
|
"scribe_addresses",
|
||||||
|
["tenant_id", "default_address"],
|
||||||
|
["owner_tenant", "name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only if claude-ai already exists in jpmschweitzer (production: yes;
|
||||||
|
# core_api_test: no) - an UPDATE ... WHERE EXISTS is a no-op rather
|
||||||
|
# than a failure when it does not.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tenant_consumers
|
||||||
|
SET default_address = 'claude-ai'
|
||||||
|
WHERE tenant_id = 'jpmschweitzer'
|
||||||
|
AND consumer = 'office-claude'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM scribe_addresses
|
||||||
|
WHERE owner_tenant = 'jpmschweitzer' AND name = 'claude-ai'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Unbind and drop the column."""
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_tenant_consumers_default_address", "tenant_consumers", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_column("tenant_consumers", "default_address")
|
||||||
@@ -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]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.12.0"
|
version = "1.16.0"
|
||||||
description = "Core Code API - Infrastructure management and tools API"
|
description = "Core Code API - Infrastructure management and tools API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
Scribe Controller
|
Scribe Controller
|
||||||
|
|
||||||
The Scribe's messaging verbs (dossier core-api T-5): register, list
|
The Scribe's messaging verbs (dossier core-api T-5): register, list
|
||||||
(addresses), send, mailbox, history, find, mark, tag/untag. Every route is
|
(addresses), send, mailbox, history, find, mark, tag/untag, archive/trash
|
||||||
behind an Office or user caller (T-1/T-4); every query filters on the
|
a mailbox entry and look one up by message number (core-api T-13/T-9).
|
||||||
caller's tenant (D-73) — an address or message in another tenant answers
|
Every route is behind an Office or user caller (T-1/T-4); every query
|
||||||
exactly like one that does not exist.
|
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
|
from typing import Optional
|
||||||
|
|
||||||
@@ -20,6 +25,7 @@ from src.domains.scribe.schemas import (
|
|||||||
MailboxEntryResponse,
|
MailboxEntryResponse,
|
||||||
MailboxListResponse,
|
MailboxListResponse,
|
||||||
MailboxPatchRequest,
|
MailboxPatchRequest,
|
||||||
|
MailboxView,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
MessageSendRequest,
|
MessageSendRequest,
|
||||||
)
|
)
|
||||||
@@ -33,6 +39,13 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
require_scribe_reader = require_caller("office", "user")
|
require_scribe_reader = require_caller("office", "user")
|
||||||
require_scribe_writer = require_caller("office", "user", identity_required=True)
|
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:
|
def _address_response(address, owned: bool) -> AddressResponse:
|
||||||
@@ -54,6 +67,8 @@ def _entry_response(entry, message) -> MailboxEntryResponse:
|
|||||||
id=entry.id,
|
id=entry.id,
|
||||||
address=entry.address,
|
address=entry.address,
|
||||||
read_at=entry.read_at,
|
read_at=entry.read_at,
|
||||||
|
archived_at=entry.archived_at,
|
||||||
|
trashed_at=entry.trashed_at,
|
||||||
tags=entry.tags,
|
tags=entry.tags,
|
||||||
created_at=entry.created_at,
|
created_at=entry.created_at,
|
||||||
message=_message_response(message),
|
message=_message_response(message),
|
||||||
@@ -123,7 +138,7 @@ class ScribeController(BaseController):
|
|||||||
)
|
)
|
||||||
async def send_message(
|
async def send_message(
|
||||||
data: MessageSendRequest,
|
data: MessageSendRequest,
|
||||||
caller: Caller = Depends(require_scribe_writer),
|
caller: Caller = Depends(require_scribe_sender),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
@@ -171,16 +186,37 @@ class ScribeController(BaseController):
|
|||||||
summary="The caller identity's mailbox, unread first",
|
summary="The caller identity's mailbox, unread first",
|
||||||
)
|
)
|
||||||
async def get_mailbox(
|
async def get_mailbox(
|
||||||
|
view: MailboxView = Query(
|
||||||
|
"inbox", description="inbox (default, neither archived nor trashed), archive, trash, all"
|
||||||
|
),
|
||||||
unread: bool = Query(False),
|
unread: bool = Query(False),
|
||||||
tag: Optional[str] = Query(None),
|
tag: Optional[str] = Query(None),
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
before_id: Optional[int] = Query(None),
|
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/since_message_id (core-api T-9)",
|
||||||
|
),
|
||||||
|
since_message_id: Optional[int] = Query(
|
||||||
|
None,
|
||||||
|
description="only entries whose message number is above this one, combined "
|
||||||
|
"with view/unread/tag as usual (ignored when message_id is given) (core-api T-16)",
|
||||||
|
),
|
||||||
caller: Caller = Depends(require_scribe_reader),
|
caller: Caller = Depends(require_scribe_reader),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
results, total = await service.get_mailbox(
|
results, total, newest_message_id = 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,
|
||||||
|
since_message_id=since_message_id,
|
||||||
)
|
)
|
||||||
except ScribeError as error:
|
except ScribeError as error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -190,6 +226,7 @@ class ScribeController(BaseController):
|
|||||||
entries=[_entry_response(entry, message) for entry, message in results],
|
entries=[_entry_response(entry, message) for entry, message in results],
|
||||||
total=total,
|
total=total,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
newest_message_id=newest_message_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
|
|||||||
@@ -90,7 +90,12 @@ class ScribeMessage(Base):
|
|||||||
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False, index=True
|
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
topics: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
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)
|
from_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||||
subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
@@ -134,6 +139,12 @@ class ScribeMailboxEntry(Base):
|
|||||||
Integer, ForeignKey("scribe_messages.id"), nullable=False
|
Integer, ForeignKey("scribe_messages.id"), nullable=False
|
||||||
)
|
)
|
||||||
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
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)
|
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ Scribe Domain Schemas
|
|||||||
|
|
||||||
Pydantic request/response payloads for the Scribe (D-66, D-68, D-73). This
|
Pydantic request/response payloads for the Scribe (D-66, D-68, D-73). This
|
||||||
round ships `register`, `list` (addresses), `send`, `mailbox`, `history`,
|
round ships `register`, `list` (addresses), `send`, `mailbox`, `history`,
|
||||||
`find`, `mark`, `tag`/`untag` — groups, subscriptions, the notification
|
`find`, `mark`, `tag`/`untag`, archive/trash a mailbox entry and look one
|
||||||
matrix and delivery are later work (dossier core-api T-5).
|
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 datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Literal, Optional
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from src.domains.scribe.models import ADDRESS_NAME_PATTERN
|
from src.domains.scribe.models import ADDRESS_NAME_PATTERN
|
||||||
from src.shared.base import BaseSchema
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
|
MailboxView = Literal["inbox", "archive", "trash", "all"]
|
||||||
|
|
||||||
|
|
||||||
class AddressRegisterRequest(BaseSchema):
|
class AddressRegisterRequest(BaseSchema):
|
||||||
"""`register` — the name only; owner (tenant, consumer) comes from the caller."""
|
"""`register` — the name only; owner (tenant, consumer) comes from the caller."""
|
||||||
@@ -46,7 +49,10 @@ class MessageSendRequest(BaseSchema):
|
|||||||
class MessageResponse(BaseSchema):
|
class MessageResponse(BaseSchema):
|
||||||
id: int
|
id: int
|
||||||
topics: List[str]
|
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]
|
from_consumer: Optional[str]
|
||||||
subject: Optional[str]
|
subject: Optional[str]
|
||||||
body: str
|
body: str
|
||||||
@@ -61,6 +67,8 @@ class MailboxEntryResponse(BaseSchema):
|
|||||||
id: int
|
id: int
|
||||||
address: str
|
address: str
|
||||||
read_at: Optional[datetime]
|
read_at: Optional[datetime]
|
||||||
|
archived_at: Optional[datetime]
|
||||||
|
trashed_at: Optional[datetime]
|
||||||
tags: List[str]
|
tags: List[str]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
message: MessageResponse
|
message: MessageResponse
|
||||||
@@ -70,6 +78,14 @@ class MailboxListResponse(BaseSchema):
|
|||||||
entries: List[MailboxEntryResponse]
|
entries: List[MailboxEntryResponse]
|
||||||
total: int
|
total: int
|
||||||
limit: int
|
limit: int
|
||||||
|
# Only `GET /scribe/mailbox` sets this (core-api T-16); `GET /scribe/messages`
|
||||||
|
# (find) shares this schema and leaves it at the default.
|
||||||
|
newest_message_id: Optional[int] = Field(
|
||||||
|
None,
|
||||||
|
description="the caller's newest message number across the whole mailbox "
|
||||||
|
"(every view), regardless of the filters this call applied; null when the "
|
||||||
|
"mailbox is empty",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class HistoryResponse(BaseSchema):
|
class HistoryResponse(BaseSchema):
|
||||||
@@ -78,8 +94,13 @@ class HistoryResponse(BaseSchema):
|
|||||||
|
|
||||||
|
|
||||||
class MailboxPatchRequest(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
|
read: Optional[bool] = None
|
||||||
|
archived: Optional[bool] = None
|
||||||
|
trashed: Optional[bool] = None
|
||||||
add_tags: Optional[List[str]] = None
|
add_tags: Optional[List[str]] = None
|
||||||
remove_tags: Optional[List[str]] = None
|
remove_tags: Optional[List[str]] = None
|
||||||
|
|||||||
+144
-19
@@ -62,6 +62,19 @@ class ScribeService:
|
|||||||
except TenantError as error:
|
except TenantError as error:
|
||||||
raise ScribeError(error.code, error.status_code) from 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:
|
def _validate_address_name(self, name: str) -> None:
|
||||||
"""Beyond the schema's regex (shape only): not `group-*`, not reserved.
|
"""Beyond the schema's regex (shape only): not `group-*`, not reserved.
|
||||||
Mirrors src/domains/auth/callers.py's `_validate_identity` rule —
|
Mirrors src/domains/auth/callers.py's `_validate_identity` rule —
|
||||||
@@ -90,6 +103,22 @@ class ScribeService:
|
|||||||
messages = (await session.execute(stmt)).scalars().all()
|
messages = (await session.execute(stmt)).scalars().all()
|
||||||
return {message.id: message for message in messages}
|
return {message.id: message for message in messages}
|
||||||
|
|
||||||
|
async def _newest_message_id(
|
||||||
|
self, session: AsyncSession, tenant: str, identity: str
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""The caller's newest message number across its whole mailbox —
|
||||||
|
every view, independent of whatever filters the current call
|
||||||
|
applies (core-api T-16) — scoped to tenant *and* address so it can
|
||||||
|
never answer with another identity's or tenant's number (D-73).
|
||||||
|
`func.max` over no rows is SQL NULL, which the driver returns as
|
||||||
|
`None`: the empty-mailbox case falls out of the query rather than
|
||||||
|
needing a separate branch."""
|
||||||
|
stmt = select(func.max(ScribeMailboxEntry.message_id)).where(
|
||||||
|
ScribeMailboxEntry.owner_tenant == tenant,
|
||||||
|
ScribeMailboxEntry.address == identity,
|
||||||
|
)
|
||||||
|
return (await session.execute(stmt)).scalar()
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# register / list addresses
|
# register / list addresses
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -142,14 +171,38 @@ class ScribeService:
|
|||||||
async def send_message(
|
async def send_message(
|
||||||
self, session: AsyncSession, data: MessageSendRequest, caller: Caller
|
self, session: AsyncSession, data: MessageSendRequest, caller: Caller
|
||||||
) -> ScribeMessage:
|
) -> 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)
|
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
|
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(
|
found_stmt = select(ScribeAddress.name).where(
|
||||||
ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients)
|
ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients)
|
||||||
)
|
)
|
||||||
@@ -163,13 +216,11 @@ class ScribeService:
|
|||||||
if reply_target is None or reply_target.owner_tenant != tenant:
|
if reply_target is None or reply_target.owner_tenant != tenant:
|
||||||
raise ScribeError("not_found", 404)
|
raise ScribeError("not_found", 404)
|
||||||
|
|
||||||
tags = list(dict.fromkeys(data.tags))
|
|
||||||
|
|
||||||
message = ScribeMessage(
|
message = ScribeMessage(
|
||||||
owner_tenant=tenant,
|
owner_tenant=tenant,
|
||||||
topics=recipients,
|
topics=recipients,
|
||||||
from_identity=caller.identity,
|
from_identity=from_identity,
|
||||||
from_consumer=caller.consumer,
|
from_consumer=from_consumer,
|
||||||
subject=data.subject,
|
subject=data.subject,
|
||||||
body=data.body,
|
body=data.body,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
@@ -187,7 +238,8 @@ class ScribeService:
|
|||||||
await session.refresh(message)
|
await session.refresh(message)
|
||||||
|
|
||||||
logger.info(
|
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
|
return message
|
||||||
|
|
||||||
@@ -199,25 +251,79 @@ class ScribeService:
|
|||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
caller: Caller,
|
caller: Caller,
|
||||||
|
view: str = "inbox",
|
||||||
unread: Optional[bool] = None,
|
unread: Optional[bool] = None,
|
||||||
tag: Optional[str] = None,
|
tag: Optional[str] = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
before_id: Optional[int] = None,
|
before_id: Optional[int] = None,
|
||||||
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
|
message_id: Optional[int] = None,
|
||||||
"""`mailbox` — the caller's identity's entries, unread first."""
|
since_message_id: Optional[int] = None,
|
||||||
|
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int, Optional[int]]:
|
||||||
|
"""`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`/`since_message_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.
|
||||||
|
`since_message_id`, when given, keeps only entries whose message
|
||||||
|
number is above it (strictly — the boundary message itself is not
|
||||||
|
"since" itself), combined with `view`/`unread`/`tag` the same way
|
||||||
|
they combine with each other (core-api T-16).
|
||||||
|
|
||||||
|
Returns `(results, total, newest_message_id)`. `total` counts
|
||||||
|
whatever matched the filters above, independent of `limit` — true
|
||||||
|
before this round too, since the count query already runs against
|
||||||
|
the pre-`limit` subquery; `since_message_id` becomes one more
|
||||||
|
filter it counts against, nothing more. `newest_message_id` is
|
||||||
|
**not** scoped by any of those filters — see `_newest_message_id`.
|
||||||
|
"""
|
||||||
tenant = await self._require_tenant(session, caller)
|
tenant = await self._require_tenant(session, caller)
|
||||||
await self._require_owned_identity(session, caller, tenant)
|
await self._require_owned_identity(session, caller, tenant)
|
||||||
|
|
||||||
|
newest_message_id = await self._newest_message_id(session, tenant, caller.identity)
|
||||||
|
|
||||||
|
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), newest_message_id
|
||||||
|
|
||||||
conditions = [
|
conditions = [
|
||||||
ScribeMailboxEntry.owner_tenant == tenant,
|
ScribeMailboxEntry.owner_tenant == tenant,
|
||||||
ScribeMailboxEntry.address == caller.identity,
|
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:
|
if unread:
|
||||||
conditions.append(ScribeMailboxEntry.read_at.is_(None))
|
conditions.append(ScribeMailboxEntry.read_at.is_(None))
|
||||||
if tag is not None:
|
if tag is not None:
|
||||||
conditions.append(ScribeMailboxEntry.tags.contains([tag]))
|
conditions.append(ScribeMailboxEntry.tags.contains([tag]))
|
||||||
if before_id is not None:
|
if before_id is not None:
|
||||||
conditions.append(ScribeMailboxEntry.id < before_id)
|
conditions.append(ScribeMailboxEntry.id < before_id)
|
||||||
|
if since_message_id is not None:
|
||||||
|
conditions.append(ScribeMailboxEntry.message_id > since_message_id)
|
||||||
|
|
||||||
base_query = select(ScribeMailboxEntry).where(*conditions)
|
base_query = select(ScribeMailboxEntry).where(*conditions)
|
||||||
count_query = select(func.count()).select_from(base_query.subquery())
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
@@ -231,12 +337,15 @@ class ScribeService:
|
|||||||
entries = list((await session.execute(query)).scalars().all())
|
entries = list((await session.execute(query)).scalars().all())
|
||||||
messages_by_id = await self._messages_for_entries(session, entries)
|
messages_by_id = await self._messages_for_entries(session, entries)
|
||||||
|
|
||||||
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
|
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total, newest_message_id
|
||||||
|
|
||||||
async def find_messages(
|
async def find_messages(
|
||||||
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50
|
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50
|
||||||
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
|
) -> 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)
|
tenant = await self._require_tenant(session, caller)
|
||||||
await self._require_owned_identity(session, caller, tenant)
|
await self._require_owned_identity(session, caller, tenant)
|
||||||
|
|
||||||
@@ -247,6 +356,7 @@ class ScribeService:
|
|||||||
.where(
|
.where(
|
||||||
ScribeMailboxEntry.owner_tenant == tenant,
|
ScribeMailboxEntry.owner_tenant == tenant,
|
||||||
ScribeMailboxEntry.address == caller.identity,
|
ScribeMailboxEntry.address == caller.identity,
|
||||||
|
ScribeMailboxEntry.trashed_at.is_(None),
|
||||||
ScribeMessage.search.op("@@")(tsquery),
|
ScribeMessage.search.op("@@")(tsquery),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -302,11 +412,16 @@ class ScribeService:
|
|||||||
async def patch_mailbox_entry(
|
async def patch_mailbox_entry(
|
||||||
self, session: AsyncSession, entry_id: int, data: MailboxPatchRequest, caller: Caller
|
self, session: AsyncSession, entry_id: int, data: MailboxPatchRequest, caller: Caller
|
||||||
) -> Tuple[ScribeMailboxEntry, ScribeMessage]:
|
) -> Tuple[ScribeMailboxEntry, ScribeMessage]:
|
||||||
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share
|
"""`mark` (`read`), `archive`/`unarchive`, `trash`/`restore` and
|
||||||
this route. An entry outside the caller's tenant, or addressed to
|
`tag`/`untag` (`add_tags`/`remove_tags`) share this route. An entry
|
||||||
an identity the caller is not reading as, answers 404 not_found —
|
outside the caller's tenant, or addressed to an identity the caller
|
||||||
indistinguishable from a nonexistent entry, so an id never confirms
|
is not reading as, answers 404 not_found — indistinguishable from a
|
||||||
someone else's mail exists."""
|
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)
|
tenant = await self._require_tenant(session, caller)
|
||||||
|
|
||||||
entry = await session.get(ScribeMailboxEntry, entry_id)
|
entry = await session.get(ScribeMailboxEntry, entry_id)
|
||||||
@@ -318,6 +433,16 @@ class ScribeService:
|
|||||||
elif data.read is False:
|
elif data.read is False:
|
||||||
entry.read_at = None
|
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:
|
if data.add_tags:
|
||||||
current = set(entry.tags)
|
current = set(entry.tags)
|
||||||
entry.tags = list(entry.tags) + [t for t in data.add_tags if t not in current]
|
entry.tags = list(entry.tags) + [t for t in data.add_tags if t not in current]
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class TenantsController(BaseController):
|
|||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
tenant, consumers = await service.get_me(session, caller)
|
tenant, consumers, default_address = await service.get_me(session, caller)
|
||||||
except TenantError as error:
|
except TenantError as error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=error.status_code, detail={"code": error.code}
|
status_code=error.status_code, detail={"code": error.code}
|
||||||
@@ -62,6 +62,7 @@ class TenantsController(BaseController):
|
|||||||
kind=tenant.kind,
|
kind=tenant.kind,
|
||||||
status=tenant.status,
|
status=tenant.status,
|
||||||
consumers=consumers,
|
consumers=consumers,
|
||||||
|
default_address=default_address,
|
||||||
)
|
)
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|||||||
@@ -7,10 +7,25 @@ and `TenantsService.require_tenant` is the one place a caller's tenant is
|
|||||||
checked known, active, and — for an Office caller — granted to the calling
|
checked known, active, and — for an Office caller — granted to the calling
|
||||||
consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
||||||
src/domains/book/models.py.
|
src/domains/book/models.py.
|
||||||
|
|
||||||
|
`TenantConsumer.default_address` (migration 008, core-api T-11) is each
|
||||||
|
grant's fallback address for a caller that names none and holds no session
|
||||||
|
binding — claude.ai's connector keeps no MCP session between tool calls, so
|
||||||
|
the Office needs this to answer without one. The composite FK keeps it
|
||||||
|
honest: a default always names a real address in the same tenant.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Text, func
|
from sqlalchemy import (
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
ForeignKeyConstraint,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from src.shared.database import Base
|
from src.shared.database import Base
|
||||||
@@ -55,14 +70,26 @@ class Tenant(Base):
|
|||||||
|
|
||||||
|
|
||||||
class TenantConsumer(Base):
|
class TenantConsumer(Base):
|
||||||
"""A grant: consumer `consumer` may act in tenant `tenant_id`."""
|
"""A grant: consumer `consumer` may act in tenant `tenant_id`, with an
|
||||||
|
optional default address (migration 008, core-api T-11) — the composite
|
||||||
|
FK below means a non-null default always names a real
|
||||||
|
`scribe_addresses` row in this same tenant."""
|
||||||
|
|
||||||
__tablename__ = "tenant_consumers"
|
__tablename__ = "tenant_consumers"
|
||||||
|
__table_args__ = (
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["tenant_id", "default_address"],
|
||||||
|
["scribe_addresses.owner_tenant", "scribe_addresses.name"],
|
||||||
|
name="fk_tenant_consumers_default_address",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
tenant_id: Mapped[str] = mapped_column(
|
tenant_id: Mapped[str] = mapped_column(
|
||||||
String(63), ForeignKey("tenants.id"), primary_key=True
|
String(63), ForeignKey("tenants.id"), primary_key=True
|
||||||
)
|
)
|
||||||
consumer: Mapped[str] = mapped_column(String(63), primary_key=True)
|
consumer: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||||
|
# FK to scribe_addresses (owner_tenant, name) added by migration 008.
|
||||||
|
default_address: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Pydantic request/response payloads for the tenant registry (core-api T-8 /
|
|||||||
workspace D-73). No write routes this round — provisioning is later
|
workspace D-73). No write routes this round — provisioning is later
|
||||||
(workspace T-195).
|
(workspace T-195).
|
||||||
"""
|
"""
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from src.shared.base import BaseSchema
|
from src.shared.base import BaseSchema
|
||||||
|
|
||||||
@@ -21,9 +21,14 @@ class TenantSummary(BaseSchema):
|
|||||||
|
|
||||||
class TenantMeResponse(TenantSummary):
|
class TenantMeResponse(TenantSummary):
|
||||||
"""`GET /tenants/me` — the caller's own tenant plus the consumers
|
"""`GET /tenants/me` — the caller's own tenant plus the consumers
|
||||||
granted in it, the cheap "am I allowed here" probe (D-73)."""
|
granted in it, the cheap "am I allowed here" probe (D-73).
|
||||||
|
|
||||||
|
`default_address` (core-api T-11) is the caller's own consumer's
|
||||||
|
default address, or null — null for a user-kind caller (no consumer to
|
||||||
|
look a default up for) and for an office caller whose grant has none."""
|
||||||
|
|
||||||
consumers: List[str]
|
consumers: List[str]
|
||||||
|
default_address: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TenantListResponse(BaseSchema):
|
class TenantListResponse(BaseSchema):
|
||||||
|
|||||||
@@ -81,9 +81,16 @@ class TenantsService:
|
|||||||
|
|
||||||
return tenant
|
return tenant
|
||||||
|
|
||||||
async def get_me(self, session: AsyncSession, caller: Caller) -> Tuple[Tenant, List[str]]:
|
async def get_me(
|
||||||
|
self, session: AsyncSession, caller: Caller
|
||||||
|
) -> Tuple[Tenant, List[str], Optional[str]]:
|
||||||
"""`GET /tenants/me` — through the same helper, so "am I allowed
|
"""`GET /tenants/me` — through the same helper, so "am I allowed
|
||||||
here" shares its answer with every other tenant-scoped call."""
|
here" shares its answer with every other tenant-scoped call.
|
||||||
|
|
||||||
|
The third element is the caller's own consumer's default address
|
||||||
|
(core-api T-11), or None — looked up only for an office caller
|
||||||
|
(`caller.consumer is not None`); a user-kind caller carries no
|
||||||
|
consumer, so there is no grant row to hold a default for it."""
|
||||||
tenant_id = await self.require_tenant(session, caller)
|
tenant_id = await self.require_tenant(session, caller)
|
||||||
|
|
||||||
tenant = await session.get(Tenant, tenant_id)
|
tenant = await session.get(Tenant, tenant_id)
|
||||||
@@ -93,7 +100,16 @@ class TenantsService:
|
|||||||
.order_by(TenantConsumer.consumer)
|
.order_by(TenantConsumer.consumer)
|
||||||
)
|
)
|
||||||
consumers = list((await session.execute(consumers_stmt)).scalars().all())
|
consumers = list((await session.execute(consumers_stmt)).scalars().all())
|
||||||
return tenant, consumers
|
|
||||||
|
default_address: Optional[str] = None
|
||||||
|
if caller.consumer is not None:
|
||||||
|
default_address = await session.scalar(
|
||||||
|
select(TenantConsumer.default_address).where(
|
||||||
|
TenantConsumer.tenant_id == tenant_id,
|
||||||
|
TenantConsumer.consumer == caller.consumer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tenant, consumers, default_address
|
||||||
|
|
||||||
async def list_tenants(self, session: AsyncSession, caller: Caller) -> List[Tenant]:
|
async def list_tenants(self, session: AsyncSession, caller: Caller) -> List[Tenant]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+117
-30
@@ -8,15 +8,16 @@ other `POSTGRES_*` values point there too, so this is the one guard that
|
|||||||
stands between a test run and the production database.
|
stands between a test run and the production database.
|
||||||
|
|
||||||
Running the actual migration against the test database is done through the
|
Running the actual migration against the test database is done through the
|
||||||
`alembic` CLI in a subprocess rather than in-process, because
|
`alembic` CLI in a subprocess rather than in-process, because `alembic/env.py`
|
||||||
`alembic/env.py` always builds its connection URL from `get_settings()`
|
builds its connection in a fresh interpreter each time, unaffected by this
|
||||||
(composed from `POSTGRES_*`), with no override hook of its own — and
|
process's `lru_cache`d `get_settings()` — by the time a test runs, other test
|
||||||
`get_settings()` is `lru_cache`d, so by the time a test runs, other test
|
|
||||||
modules have almost certainly already imported `src.main` and cached a
|
modules have almost certainly already imported `src.main` and cached a
|
||||||
Settings instance pointed at production. A subprocess gets a clean
|
Settings instance pointed at production. `run_alembic` always passes
|
||||||
environment and a clean cache; overriding `POSTGRES_HOST`/`USER`/
|
`-x target=test` (core-api T-3: alembic refuses to run with no target, or
|
||||||
`PASSWORD`/`DATABASE` in that child's environment, derived from
|
any other one) and sets `TEST_DATABASE_URL` in the child's environment to
|
||||||
`TEST_DATABASE_URL`, is what actually redirects the migration.
|
`url`, which is what actually redirects the migration — the subprocess's own
|
||||||
|
`alembic/env.py` reads that variable itself; nothing here overrides
|
||||||
|
`POSTGRES_*` any more.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
@@ -33,26 +34,13 @@ ALEMBIC_BIN = REPO_ROOT / ".venv" / "bin" / "alembic"
|
|||||||
load_dotenv(REPO_ROOT / ".env")
|
load_dotenv(REPO_ROOT / ".env")
|
||||||
|
|
||||||
|
|
||||||
def _components(url: str) -> dict:
|
|
||||||
"""POSTGRES_* overrides for a subprocess, derived from a database URL."""
|
|
||||||
parts = urlsplit(url)
|
|
||||||
host = parts.hostname or ""
|
|
||||||
if parts.port:
|
|
||||||
host = f"{host}:{parts.port}"
|
|
||||||
return {
|
|
||||||
"POSTGRES_HOST": host,
|
|
||||||
"POSTGRES_USER": parts.username or "",
|
|
||||||
"POSTGRES_PASSWORD": parts.password or "",
|
|
||||||
"POSTGRES_DATABASE": parts.path.lstrip("/"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess:
|
def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess:
|
||||||
"""Run `alembic <args>` against `url`, never against the repo's own .env database."""
|
"""Run `alembic -x target=test <args>` against `url`, never against the
|
||||||
|
repo's own .env database."""
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.update(_components(url))
|
env["TEST_DATABASE_URL"] = url
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[str(ALEMBIC_BIN), *args],
|
[str(ALEMBIC_BIN), "-x", "target=test", *args],
|
||||||
cwd=str(REPO_ROOT),
|
cwd=str(REPO_ROOT),
|
||||||
env=env,
|
env=env,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
@@ -61,6 +49,23 @@ def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class MaskedURL(str):
|
||||||
|
"""A database URL, fully usable as a plain string (passed to `.replace()`,
|
||||||
|
`urlsplit()`, a subprocess environment, ...) but never through `repr()`
|
||||||
|
(core-api T-12): pytest prints a failing test's fixture arguments using
|
||||||
|
`repr()`, with no `-v`/`--showlocals` needed to trigger it — that is how
|
||||||
|
a failing database test used to print this fixture's password straight
|
||||||
|
into the terminal. `__str__` is deliberately left alone, so the real
|
||||||
|
value is still what every caller actually connects with."""
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
parts = urlsplit(self)
|
||||||
|
netloc = parts.netloc
|
||||||
|
if parts.password:
|
||||||
|
netloc = netloc.replace(f":{parts.password}@", ":***@")
|
||||||
|
return repr(parts._replace(netloc=netloc).geturl())
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def test_database_url() -> str:
|
def test_database_url() -> str:
|
||||||
"""The core_api_test database URL, or skip/refuse per the rules above."""
|
"""The core_api_test database URL, or skip/refuse per the rules above."""
|
||||||
@@ -74,7 +79,7 @@ def test_database_url() -> str:
|
|||||||
"TEST_DATABASE_URL resolves to 'core_api' (production) — refusing to use it for tests"
|
"TEST_DATABASE_URL resolves to 'core_api' (production) — refusing to use it for tests"
|
||||||
)
|
)
|
||||||
|
|
||||||
return url
|
return MaskedURL(url)
|
||||||
|
|
||||||
|
|
||||||
def _grant_test_office_gemini(url: str) -> None:
|
def _grant_test_office_gemini(url: str) -> None:
|
||||||
@@ -225,13 +230,36 @@ async def scribe_session(scribe_schema: str):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def clean_scribe_tables(scribe_session):
|
async def clean_scribe_tables(scribe_session):
|
||||||
"""Scribe service methods commit internally (same reason as Book's
|
"""Scribe service methods commit internally (same reason as Book's
|
||||||
clean_book_tables). TRUNCATE all three tables together so Postgres
|
clean_book_tables). TRUNCATE all tables together so Postgres resolves
|
||||||
resolves the FK order (mailbox_entries -> messages, messages'
|
the FK order (mailbox_entries -> messages, messages' self-referencing
|
||||||
self-referencing reply_to) across the one statement."""
|
reply_to) across the one statement.
|
||||||
|
|
||||||
|
`tenant_consumers` joined the list for migration 008 (core-api T-11):
|
||||||
|
its new composite FK `(tenant_id, default_address)` ->
|
||||||
|
`scribe_addresses(owner_tenant, name)` means Postgres now refuses to
|
||||||
|
truncate `scribe_addresses` alone — "cannot truncate a table
|
||||||
|
referenced in a foreign key constraint" — a restriction on the
|
||||||
|
constraint's existence, not on whether any row's `default_address` is
|
||||||
|
actually non-null (it never is here; migration 008's data step is a
|
||||||
|
no-op in `core_api_test`). `TRUNCATE ... CASCADE` was rejected: it
|
||||||
|
would silently drop every grant rather than only addresses, so the
|
||||||
|
four grants this file's tests depend on — both seeded tenants x
|
||||||
|
office-claude/office-gemini, the same pairing `_grant_test_office_gemini`
|
||||||
|
establishes once per session — are reinserted immediately after."""
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
await scribe_session.execute(
|
await scribe_session.execute(
|
||||||
text("TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses")
|
text(
|
||||||
|
"TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses, "
|
||||||
|
"tenant_consumers"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await scribe_session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO tenant_consumers (tenant_id, consumer) VALUES "
|
||||||
|
"('jpmschweitzer', 'office-claude'), ('llm_tester', 'office-claude'), "
|
||||||
|
"('jpmschweitzer', 'office-gemini'), ('llm_tester', 'office-gemini')"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await scribe_session.commit()
|
await scribe_session.commit()
|
||||||
yield scribe_session
|
yield scribe_session
|
||||||
@@ -287,3 +315,62 @@ async def suspended_tenant(test_database_url: str):
|
|||||||
await session.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id})
|
await session.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id})
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def default_address_grant(tenant_registry: str, test_database_url: str):
|
||||||
|
"""
|
||||||
|
Give `jpmschweitzer`/`office-claude` a default address (core-api T-11):
|
||||||
|
registers a throwaway `scribe_addresses` row first — migration 008's
|
||||||
|
composite FK means the grant's default must name a real address in the
|
||||||
|
same tenant — then points the grant at it. Reversed on teardown (default
|
||||||
|
back to null, the address row deleted) so the rest of the suite sees the
|
||||||
|
null default that `core_api_test`'s own copy of migration 008 actually
|
||||||
|
leaves this grant with (see test_tenants_migration.py's
|
||||||
|
TestMigration008DataStep: claude-ai is never seeded here).
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
tenant_id = "jpmschweitzer"
|
||||||
|
consumer = "office-claude"
|
||||||
|
address = "test-default-address"
|
||||||
|
|
||||||
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||||
|
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO scribe_addresses (owner_tenant, name, created_by) "
|
||||||
|
"VALUES (:tenant, :address, 'test-harness')"
|
||||||
|
),
|
||||||
|
{"tenant": tenant_id, "address": address},
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tenant_consumers SET default_address = :address "
|
||||||
|
"WHERE tenant_id = :tenant AND consumer = :consumer"
|
||||||
|
),
|
||||||
|
{"tenant": tenant_id, "address": address, "consumer": consumer},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
yield address
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
await session.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tenant_consumers SET default_address = NULL "
|
||||||
|
"WHERE tenant_id = :tenant AND consumer = :consumer"
|
||||||
|
),
|
||||||
|
{"tenant": tenant_id, "consumer": consumer},
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
text("DELETE FROM scribe_addresses WHERE owner_tenant = :tenant AND name = :address"),
|
||||||
|
{"tenant": tenant_id, "address": address},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await engine.dispose()
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Tests for core-api T-3: alembic refuses to run without a named target.
|
||||||
|
|
||||||
|
`alembic/env.py` resolves `-x target=test` or `-x target=production`; no
|
||||||
|
target, or anything else, must exit non-zero before any engine is built —
|
||||||
|
not skip, not fall back to the production settings a bare `alembic current`
|
||||||
|
used to reach by accident. Both tests point the *production* settings
|
||||||
|
(`POSTGRES_*`) at an unreachable host: if the refusal ever started
|
||||||
|
connecting before checking the target, these would hang (and the
|
||||||
|
subprocess timeout below would fire) instead of returning quickly.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from tests.conftest import ALEMBIC_BIN, REPO_ROOT
|
||||||
|
|
||||||
|
UNREACHABLE_HOST = "10.255.255.1" # non-routed; a real connection attempt would hang
|
||||||
|
|
||||||
|
|
||||||
|
def _run_with_unreachable_production(*args: str) -> subprocess.CompletedProcess:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["POSTGRES_HOST"] = UNREACHABLE_HOST
|
||||||
|
env["POSTGRES_USER"] = "probe"
|
||||||
|
env["POSTGRES_PASSWORD"] = "probe"
|
||||||
|
env["POSTGRES_DATABASE"] = "probe"
|
||||||
|
env.pop("TEST_DATABASE_URL", None)
|
||||||
|
return subprocess.run(
|
||||||
|
[str(ALEMBIC_BIN), *args],
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10, # a real connection attempt to an unreachable host hangs well past this
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_target_refuses_before_connecting():
|
||||||
|
result = _run_with_unreachable_production("current")
|
||||||
|
assert result.returncode != 0
|
||||||
|
combined = result.stdout + result.stderr
|
||||||
|
assert "target=test" in combined
|
||||||
|
assert "target=production" in combined
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonsense_target_refuses_before_connecting():
|
||||||
|
result = _run_with_unreachable_production("-x", "target=nonsense", "current")
|
||||||
|
assert result.returncode != 0
|
||||||
|
combined = result.stdout + result.stderr
|
||||||
|
assert "target=test" in combined
|
||||||
|
assert "target=production" in combined
|
||||||
@@ -65,8 +65,8 @@ class TestMigration005RoundTrip:
|
|||||||
assert not await _table_exists(test_database_url, "book_events")
|
assert not await _table_exists(test_database_url, "book_events")
|
||||||
assert not await _table_exists(test_database_url, "book_tenant_counters")
|
assert not await _table_exists(test_database_url, "book_tenant_counters")
|
||||||
|
|
||||||
# Leave the schema at head for the rest of the suite.
|
# Leave the schema at head for the rest of the suite (core-api T-14).
|
||||||
up = run_alembic(test_database_url, "upgrade", "005")
|
up = run_alembic(test_database_url, "upgrade", "head")
|
||||||
assert up.returncode == 0, up.stderr
|
assert up.returncode == 0, up.stderr
|
||||||
assert await _table_exists(test_database_url, "book_records")
|
assert await _table_exists(test_database_url, "book_records")
|
||||||
assert await _table_exists(test_database_url, "book_events")
|
assert await _table_exists(test_database_url, "book_events")
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Tests for conftest.py's own test-infrastructure fixtures.
|
||||||
|
|
||||||
|
core-api T-12: a failing database test used to print the test database
|
||||||
|
password, because pytest renders a failing test's fixture arguments with
|
||||||
|
`repr()` — no `-v` or `--showlocals` needed to trigger it, just a test that
|
||||||
|
takes `test_database_url` and fails. `MaskedURL` (conftest.py) is the fix:
|
||||||
|
same string, same connection, but a `repr()` that never shows the password.
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from tests.conftest import MaskedURL
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_fixture_renders_without_its_password(test_database_url):
|
||||||
|
"""Mutation check: revert `MaskedURL.__repr__` to the inherited `str`
|
||||||
|
one (or hand back a plain `str` from the fixture) and this fails — the
|
||||||
|
real password would appear in `repr(test_database_url)`, exactly as it
|
||||||
|
did in a failing test's traceback before T-12."""
|
||||||
|
password = urlsplit(test_database_url).password
|
||||||
|
assert password, "fixture must carry a real password for this test to mean anything"
|
||||||
|
|
||||||
|
rendered = repr(test_database_url)
|
||||||
|
|
||||||
|
assert password not in rendered
|
||||||
|
# Host and database name are still visible — only the password is masked.
|
||||||
|
assert "***" in rendered
|
||||||
|
assert urlsplit(test_database_url).path in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_masked_url_is_still_a_fully_usable_string():
|
||||||
|
"""The wrapper changes only `repr()`; every other string operation the
|
||||||
|
rest of the suite relies on (`.replace()`, `urlsplit()`, use as a
|
||||||
|
subprocess environment value) must see the real value unchanged."""
|
||||||
|
raw = "postgresql://core_api_test:s3cr3t@localhost:5432/core_api_test"
|
||||||
|
masked = MaskedURL(raw)
|
||||||
|
|
||||||
|
assert masked == raw
|
||||||
|
assert str(masked) == raw
|
||||||
|
assert masked.replace("postgresql://", "postgresql+asyncpg://", 1) == raw.replace(
|
||||||
|
"postgresql://", "postgresql+asyncpg://", 1
|
||||||
|
)
|
||||||
|
assert urlsplit(masked).password == "s3cr3t"
|
||||||
|
assert "s3cr3t" not in repr(masked)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Tests for core-api T-3's `make dev-test-server` target: it must refuse to
|
||||||
|
start when the resolved database is not `core_api_test`, before uvicorn ever
|
||||||
|
binds the port, and it must never print credentials while doing so."""
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from tests.conftest import REPO_ROOT
|
||||||
|
|
||||||
|
PORT = 8777
|
||||||
|
|
||||||
|
|
||||||
|
def _port_is_free(port: int) -> bool:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
return s.connect_ex(("localhost", port)) != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_refuses_when_resolved_database_is_not_core_api_test(test_database_url):
|
||||||
|
password = urlsplit(test_database_url).password
|
||||||
|
wrong = test_database_url.rsplit("/", 1)[0] + "/core_api"
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["TEST_DATABASE_URL"] = wrong
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["make", "dev-test-server"],
|
||||||
|
cwd=str(REPO_ROOT),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
combined = result.stdout + result.stderr
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert "core_api_test" in combined
|
||||||
|
if password:
|
||||||
|
assert password not in combined
|
||||||
|
assert _port_is_free(PORT), "dev-test-server must never bind the port when it refuses"
|
||||||
@@ -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
|
engine/session per call because TestClient drives the ASGI app on a
|
||||||
different event loop than pytest-asyncio's fixture setup.
|
different event loop than pytest-asyncio's fixture setup.
|
||||||
"""
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
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):
|
def register(client, identity: str, consumer: str, tenant: str = TENANT):
|
||||||
return client.post(
|
return client.post(
|
||||||
"/scribe/addresses",
|
"/scribe/addresses",
|
||||||
@@ -403,6 +416,121 @@ class TestSendValidation:
|
|||||||
assert reply.json()["detail"]["code"] == "not_found"
|
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
|
# Isolation (dossier verification #3): the hard tenant boundary, both
|
||||||
# directions — every cross-tenant answer is indistinguishable from "does
|
# directions — every cross-tenant answer is indistinguishable from "does
|
||||||
@@ -618,3 +746,520 @@ class TestTenantRegistryEnforcement:
|
|||||||
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant=TENANT)
|
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant=TENANT)
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# since_message_id and newest_message_id (core-api T-16): the Office's one
|
||||||
|
# polling call, `glance`, answers "has anything changed since I last
|
||||||
|
# looked?" with these.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestSinceMessageId:
|
||||||
|
def test_since_message_id_returns_only_entries_above_it(self, client):
|
||||||
|
register(client, "jeroen", "office-claude")
|
||||||
|
register(client, "tower-control", "office-gemini")
|
||||||
|
ids = []
|
||||||
|
for body in ("one", "two", "three"):
|
||||||
|
sent = client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": body},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
).json()
|
||||||
|
ids.append(sent["id"])
|
||||||
|
|
||||||
|
mailbox = client.get(
|
||||||
|
"/scribe/mailbox",
|
||||||
|
params={"since_message_id": ids[0]},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
).json()
|
||||||
|
returned_ids = {e["message"]["id"] for e in mailbox["entries"]}
|
||||||
|
assert returned_ids == {ids[1], ids[2]}
|
||||||
|
assert mailbox["total"] == 2
|
||||||
|
|
||||||
|
def test_since_message_id_boundary_excludes_the_named_message(self, client):
|
||||||
|
"""dossier mutation check #1: `>=` would include message N itself;
|
||||||
|
`since_message_id=N` means strictly above N."""
|
||||||
|
register(client, "jeroen", "office-claude")
|
||||||
|
register(client, "tower-control", "office-gemini")
|
||||||
|
sent = client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": "the boundary message"},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
).json()
|
||||||
|
|
||||||
|
mailbox = client.get(
|
||||||
|
"/scribe/mailbox",
|
||||||
|
params={"since_message_id": sent["id"]},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
).json()
|
||||||
|
assert mailbox["entries"] == []
|
||||||
|
assert mailbox["total"] == 0
|
||||||
|
|
||||||
|
def test_since_message_id_combines_with_unread_and_a_view(self, client):
|
||||||
|
register(client, "jeroen", "office-claude")
|
||||||
|
register(client, "tower-control", "office-gemini")
|
||||||
|
first = client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": "first, will be read"},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
).json()
|
||||||
|
second = client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": "second, stays unread"},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
).json()
|
||||||
|
first_entry_id = client.get(
|
||||||
|
"/scribe/mailbox",
|
||||||
|
params={"message_id": first["id"]},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
).json()["entries"][0]["id"]
|
||||||
|
client.patch(
|
||||||
|
f"/scribe/mailbox/{first_entry_id}",
|
||||||
|
json={"read": True},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
)
|
||||||
|
|
||||||
|
mailbox = client.get(
|
||||||
|
"/scribe/mailbox",
|
||||||
|
params={"since_message_id": 0, "unread": True, "view": "inbox"},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
).json()
|
||||||
|
returned_ids = {e["message"]["id"] for e in mailbox["entries"]}
|
||||||
|
assert returned_ids == {second["id"]}
|
||||||
|
|
||||||
|
def test_total_counts_every_match_independent_of_limit(self, client):
|
||||||
|
"""dossier verification #2: `unread=true&limit=1` gives the unread
|
||||||
|
count in one row via `total`."""
|
||||||
|
register(client, "jeroen", "office-claude")
|
||||||
|
register(client, "tower-control", "office-gemini")
|
||||||
|
for body in ("a", "b", "c"):
|
||||||
|
client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": body},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
)
|
||||||
|
|
||||||
|
mailbox = client.get(
|
||||||
|
"/scribe/mailbox",
|
||||||
|
params={"unread": True, "limit": 1},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
).json()
|
||||||
|
assert len(mailbox["entries"]) == 1
|
||||||
|
assert mailbox["total"] == 3
|
||||||
|
|
||||||
|
def test_message_id_lookup_ignores_since_message_id(self, client):
|
||||||
|
"""`message_id` already bypasses view/unread/tag; `since_message_id`
|
||||||
|
keeps that behavior (dossier build item #1)."""
|
||||||
|
register(client, "jeroen", "office-claude")
|
||||||
|
register(client, "tower-control", "office-gemini")
|
||||||
|
sent = client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": "direct lookup"},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
).json()
|
||||||
|
|
||||||
|
found = client.get(
|
||||||
|
"/scribe/mailbox",
|
||||||
|
params={"message_id": sent["id"], "since_message_id": sent["id"]},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
).json()
|
||||||
|
assert found["total"] == 1
|
||||||
|
assert found["entries"][0]["message"]["id"] == sent["id"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewestMessageId:
|
||||||
|
def test_newest_message_id_is_true_newest_when_an_older_message_is_unread(self, client):
|
||||||
|
"""dossier core-api T-16, the exact case the Office hit: the
|
||||||
|
unread-first sort makes `limit=1` return an older unread message
|
||||||
|
when a newer one is already read. `newest_message_id` must answer
|
||||||
|
with the newer one regardless (dossier mutation check #2)."""
|
||||||
|
register(client, "jeroen", "office-claude")
|
||||||
|
register(client, "tower-control", "office-gemini")
|
||||||
|
|
||||||
|
older = client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": "older, stays unread"},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
).json()
|
||||||
|
newer = client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": "newer, gets read"},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
).json()
|
||||||
|
|
||||||
|
newer_entry_id = client.get(
|
||||||
|
"/scribe/mailbox",
|
||||||
|
params={"message_id": newer["id"]},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
).json()["entries"][0]["id"]
|
||||||
|
client.patch(
|
||||||
|
f"/scribe/mailbox/{newer_entry_id}",
|
||||||
|
json={"read": True},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
)
|
||||||
|
|
||||||
|
page = client.get(
|
||||||
|
"/scribe/mailbox",
|
||||||
|
params={"limit": 1},
|
||||||
|
headers=office_headers("tower-control", "office-gemini"),
|
||||||
|
).json()
|
||||||
|
# unread-first sort: the older, still-unread message leads page one —
|
||||||
|
# the condition newest_message_id exists to see past.
|
||||||
|
assert page["entries"][0]["message"]["id"] == older["id"]
|
||||||
|
assert page["newest_message_id"] == newer["id"]
|
||||||
|
|
||||||
|
def test_newest_message_id_is_null_for_an_empty_mailbox(self, client):
|
||||||
|
register(client, "tower-control", "office-gemini")
|
||||||
|
mailbox = client.get(
|
||||||
|
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||||
|
).json()
|
||||||
|
assert mailbox["entries"] == []
|
||||||
|
assert mailbox["newest_message_id"] is None
|
||||||
|
|
||||||
|
def test_newest_message_id_never_crosses_an_address_boundary(self, client):
|
||||||
|
"""dossier mutation check #3: dropping the address filter would let
|
||||||
|
a later message delivered to a different address in the same
|
||||||
|
tenant — a higher message number that never reaches this
|
||||||
|
identity's mailbox — leak into its newest_message_id."""
|
||||||
|
register(client, "jeroen", "office-claude")
|
||||||
|
register(client, "tower-control", "office-gemini")
|
||||||
|
register(client, "ops", "office-claude")
|
||||||
|
|
||||||
|
mine = client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": "mine"},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
).json()
|
||||||
|
client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["ops"], "body": "not mine, later and higher id"},
|
||||||
|
headers=office_headers("jeroen", "office-claude"),
|
||||||
|
)
|
||||||
|
|
||||||
|
mailbox = client.get(
|
||||||
|
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||||
|
).json()
|
||||||
|
assert mailbox["newest_message_id"] == mine["id"]
|
||||||
|
|
||||||
|
def test_newest_message_id_never_crosses_a_tenant_boundary(self, client):
|
||||||
|
"""Same concern as the address boundary above, across tenants
|
||||||
|
(D-73's hard boundary)."""
|
||||||
|
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||||
|
register(client, "tower-control", "office-gemini", tenant=TENANT)
|
||||||
|
mine = client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": "for the TENANT tower-control"},
|
||||||
|
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
|
||||||
|
).json()
|
||||||
|
|
||||||
|
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
|
||||||
|
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
|
||||||
|
client.post(
|
||||||
|
"/scribe/messages",
|
||||||
|
json={"to": ["tower-control"], "body": "for the OTHER_TENANT tower-control, later/higher id"},
|
||||||
|
headers=office_headers("jeroen", "office-claude", tenant=OTHER_TENANT),
|
||||||
|
)
|
||||||
|
|
||||||
|
mailbox = client.get(
|
||||||
|
"/scribe/mailbox",
|
||||||
|
headers=office_headers("tower-control", "office-gemini", TENANT),
|
||||||
|
).json()
|
||||||
|
assert mailbox["newest_message_id"] == mine["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
|
Runs against `core_api_test` only (see conftest.py — skip when
|
||||||
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
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()
|
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:
|
class TestMigration006RoundTrip:
|
||||||
"""Migration 006 must upgrade and downgrade cleanly (dossier verification #1)."""
|
"""Migration 006 must upgrade and downgrade cleanly (dossier verification #1)."""
|
||||||
|
|
||||||
@@ -52,9 +120,93 @@ class TestMigration006RoundTrip:
|
|||||||
# a downgrade of 006 — proves 006 only touches its own tables.
|
# a downgrade of 006 — proves 006 only touches its own tables.
|
||||||
assert await _table_exists(test_database_url, "book_records")
|
assert await _table_exists(test_database_url, "book_records")
|
||||||
|
|
||||||
# Leave the schema at head for the rest of the suite.
|
# Leave the schema at head for the rest of the suite (core-api T-14).
|
||||||
up = run_alembic(test_database_url, "upgrade", "006")
|
up = run_alembic(test_database_url, "upgrade", "head")
|
||||||
assert up.returncode == 0, up.stderr
|
assert up.returncode == 0, up.stderr
|
||||||
assert await _table_exists(test_database_url, "scribe_addresses")
|
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_messages")
|
||||||
assert await _table_exists(test_database_url, "scribe_mailbox_entries")
|
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
|
||||||
|
|||||||
@@ -164,6 +164,35 @@ class TestMe:
|
|||||||
assert response.json()["id"] == TENANT
|
assert response.json()["id"] == TENANT
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeDefaultAddress:
|
||||||
|
"""`GET /tenants/me`'s `default_address` (core-api T-11): the Office's
|
||||||
|
fallback for a caller that names no address and holds no session
|
||||||
|
binding — null unless the grant itself has one."""
|
||||||
|
|
||||||
|
def test_office_caller_with_a_default_sees_it(self, client, default_address_grant):
|
||||||
|
response = client.get("/tenants/me", headers=office_headers())
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["default_address"] == default_address_grant
|
||||||
|
|
||||||
|
def test_office_caller_without_a_default_sees_null(self, client):
|
||||||
|
response = client.get("/tenants/me", headers=office_headers())
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["default_address"] is None
|
||||||
|
|
||||||
|
def test_user_caller_sees_null(self, client):
|
||||||
|
"""A user-kind caller carries no consumer, so there is no grant row
|
||||||
|
to look a default up for — null regardless of any office grant's
|
||||||
|
own default."""
|
||||||
|
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.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["default_address"] is None
|
||||||
|
|
||||||
|
|
||||||
class TestList:
|
class TestList:
|
||||||
"""`GET /tenants` — the hard boundary: another tenant's existence is
|
"""`GET /tenants` — the hard boundary: another tenant's existence is
|
||||||
never revealed to a tenant-bearing caller."""
|
never revealed to a tenant-bearing caller."""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Tests for the tenant registry migration 007 (core-api T-8, workspace D-73).
|
Tests for the tenant registry migrations 007 (core-api T-8, workspace D-73)
|
||||||
|
and 008 (core-api T-11, the default-address column).
|
||||||
|
|
||||||
Runs against `core_api_test` only (see conftest.py — skip when
|
Runs against `core_api_test` only (see conftest.py — skip when
|
||||||
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
||||||
@@ -66,8 +67,8 @@ class TestMigration007RoundTrip:
|
|||||||
assert await _table_exists(test_database_url, "book_records")
|
assert await _table_exists(test_database_url, "book_records")
|
||||||
assert await _table_exists(test_database_url, "scribe_addresses")
|
assert await _table_exists(test_database_url, "scribe_addresses")
|
||||||
|
|
||||||
# Leave the schema at head for the rest of the suite.
|
# Leave the schema at head for the rest of the suite (core-api T-14).
|
||||||
up = run_alembic(test_database_url, "upgrade", "007")
|
up = run_alembic(test_database_url, "upgrade", "head")
|
||||||
assert up.returncode == 0, up.stderr
|
assert up.returncode == 0, up.stderr
|
||||||
assert await _table_exists(test_database_url, "tenants")
|
assert await _table_exists(test_database_url, "tenants")
|
||||||
assert await _table_exists(test_database_url, "tenant_consumers")
|
assert await _table_exists(test_database_url, "tenant_consumers")
|
||||||
@@ -186,3 +187,220 @@ class TestMigration007ForeignKeys:
|
|||||||
await conn.commit()
|
await conn.commit()
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration008RoundTrip:
|
||||||
|
"""Migration 008 (core-api T-11) must upgrade and downgrade cleanly
|
||||||
|
(dossier verification #2) — same pattern as TestMigration007RoundTrip,
|
||||||
|
one revision up."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upgrade_adds_default_address_column(self, test_database_url):
|
||||||
|
result = run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'",
|
||||||
|
)
|
||||||
|
assert found == "default_address"
|
||||||
|
|
||||||
|
@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", "007")
|
||||||
|
assert down.returncode == 0, down.stderr
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'",
|
||||||
|
)
|
||||||
|
assert found is None
|
||||||
|
# tenant_consumers itself, and the rest of the registry, must
|
||||||
|
# survive a downgrade of 008 — proves 008 only touches its own
|
||||||
|
# column and FK.
|
||||||
|
assert await _table_exists(test_database_url, "tenant_consumers")
|
||||||
|
assert await _table_exists(test_database_url, "tenants")
|
||||||
|
|
||||||
|
# Leave the schema at head for the rest of the suite (core-api T-14).
|
||||||
|
up = run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
assert up.returncode == 0, up.stderr
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT column_name FROM information_schema.columns "
|
||||||
|
"WHERE table_name = 'tenant_consumers' AND column_name = 'default_address'",
|
||||||
|
)
|
||||||
|
assert found == "default_address"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration008ForeignKey:
|
||||||
|
"""The composite FK `(tenant_id, default_address)` ->
|
||||||
|
`scribe_addresses(owner_tenant, name)` (dossier verification #2/#3): a
|
||||||
|
default always names a real address in the same tenant."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fk_exists(self, test_database_url):
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT conname FROM pg_constraint "
|
||||||
|
"WHERE conname = 'fk_tenant_consumers_default_address' AND contype = 'f'",
|
||||||
|
)
|
||||||
|
assert found == "fk_tenant_consumers_default_address"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fk_refuses_a_default_naming_an_address_that_does_not_exist(
|
||||||
|
self, test_database_url
|
||||||
|
):
|
||||||
|
"""dossier verification #3: "A test inserts a default naming an
|
||||||
|
address that does not exist and sees the FK refuse it.\""""
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
|
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tenant_consumers SET default_address = 'no-such-address' "
|
||||||
|
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fk_allows_a_null_default_regardless_of_tenant(self, test_database_url):
|
||||||
|
"""A NULL default_address never triggers the composite check
|
||||||
|
(Postgres's default MATCH SIMPLE) — explicit, since this is the
|
||||||
|
state every grant but jpmschweitzer/office-claude is left in."""
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
asyncpg_url = test_database_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(
|
||||||
|
"UPDATE tenant_consumers SET default_address = NULL "
|
||||||
|
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT default_address FROM tenant_consumers "
|
||||||
|
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'",
|
||||||
|
)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigration008DataStep:
|
||||||
|
"""jpmschweitzer/office-claude defaults to `claude-ai` only if that
|
||||||
|
address exists at upgrade time (dossier: "an UPDATE ... WHERE EXISTS
|
||||||
|
does this"); `llm_tester`/office-claude is never touched."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_is_a_noop_when_claude_ai_is_absent(self, test_database_url):
|
||||||
|
"""core_api_test seeds no scribe_addresses rows, so the data step
|
||||||
|
must run without error and leave the default null — this is the
|
||||||
|
state production's own test database is in today."""
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
down = run_alembic(test_database_url, "downgrade", "007")
|
||||||
|
assert down.returncode == 0, down.stderr
|
||||||
|
|
||||||
|
# Upgrade through to head (core-api T-14) — migrations 009/010 touch
|
||||||
|
# neither this table nor this column, so the assertion below still
|
||||||
|
# proves 008's data step alone.
|
||||||
|
up = run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
assert up.returncode == 0, up.stderr
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT default_address FROM tenant_consumers "
|
||||||
|
"WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'",
|
||||||
|
)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_llm_tester_office_claude_stays_null(self, test_database_url):
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT default_address FROM tenant_consumers "
|
||||||
|
"WHERE tenant_id = 'llm_tester' AND consumer = 'office-claude'",
|
||||||
|
)
|
||||||
|
assert found is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sets_claude_ai_when_the_address_exists_at_upgrade_time(
|
||||||
|
self, test_database_url
|
||||||
|
):
|
||||||
|
"""Register claude-ai in jpmschweitzer *before* 008 runs (the
|
||||||
|
production shape), downgrade to 007 so the data step runs again on
|
||||||
|
the way back up, then prove the UPDATE actually sets the default
|
||||||
|
rather than leaving it null regardless — the mutation check's
|
||||||
|
positive case. Cleans up its own address and resets the default so
|
||||||
|
the rest of the suite sees the null state it otherwise expects."""
|
||||||
|
run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
down = run_alembic(test_database_url, "downgrade", "007")
|
||||||
|
assert down.returncode == 0, down.stderr
|
||||||
|
|
||||||
|
asyncpg_url = test_database_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_addresses (owner_tenant, name, created_by) "
|
||||||
|
"VALUES ('jpmschweitzer', 'claude-ai', 'test-harness')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upgrade through to head (core-api T-14) — same reasoning as
|
||||||
|
# test_is_a_noop_when_claude_ai_is_absent above.
|
||||||
|
up = run_alembic(test_database_url, "upgrade", "head")
|
||||||
|
assert up.returncode == 0, up.stderr
|
||||||
|
|
||||||
|
found = await _scalar(
|
||||||
|
test_database_url,
|
||||||
|
"SELECT default_address FROM tenant_consumers "
|
||||||
|
"WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'",
|
||||||
|
)
|
||||||
|
assert found == "claude-ai"
|
||||||
|
finally:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE tenant_consumers SET default_address = NULL "
|
||||||
|
"WHERE tenant_id = 'jpmschweitzer' AND consumer = 'office-claude'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"DELETE FROM scribe_addresses "
|
||||||
|
"WHERE owner_tenant = 'jpmschweitzer' AND name = 'claude-ai'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaEndsAtHeadAfterTheMigrationSuite:
|
||||||
|
"""core-api T-14 regression guard: every round-trip test above (and in
|
||||||
|
test_book_migration.py / test_scribe_migration.py, which this file's
|
||||||
|
alphabetically-later collection runs after) must leave `core_api_test`
|
||||||
|
at head, not on its own revision. This class is the last one defined in
|
||||||
|
the last-collected migration test file, and its check is a bare
|
||||||
|
`current` — no `upgrade head` of its own — so a round-trip test that
|
||||||
|
regresses to ending on its own revision again shows up here as "not
|
||||||
|
head" rather than being silently repaired by this very check."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_schema_is_at_head(self, test_database_url):
|
||||||
|
result = run_alembic(test_database_url, "current")
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert "(head)" in result.stdout, (
|
||||||
|
f"core_api_test is not at head after the migration suite: {result.stdout!r}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user