Compare commits

..
18 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 5.5 d82535112e chore(release): 1.15.0 — a feedback message to tower-control may have no sender
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:40:24 +02:00
jpmschweitzerandClaude Sonnet 5 2e825d9080 docs(changelog): record T-15 — a feedback message to tower-control may have no sender
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 23:38:04 +02:00
jpmschweitzerandClaude Sonnet 5 8ec89336df feat(scribe): accept a feedback message to tower-control with no sender (T-15)
workspace D-70 ("`feedback` needs no address"): the failure channel must
not depend on the thing most likely to break. `scribe_messages.from_identity`
becomes nullable (migration 010); `POST /scribe/messages` accepts a missing
`X-Office-Identity` for exactly one shape — an office caller, `to` is
`["tower-control"]` alone, and `tags` contains `feedback` — stored with
`from_identity` null and `from_consumer` the caller's consumer. Every other
send without an identity is still 400 `identity_required`, enforced in
ScribeService.send_message rather than the caller dependency, since the
decision needs the request body. `MessageResponse.from_identity` is now
optional so mailbox/history/find/message_id answer such a message with a
null sender. Migration 010's downgrade refuses outright while any
sender-less row exists rather than inventing one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 23:37:46 +02:00
jpmschweitzerandClaude Opus 5.5 8b30e73661 chore(plan): T-15 — a feedback message to tower-control may have no sender
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:10:01 +02:00
jpmschweitzerandClaude Opus 5.5 7c0ff2bfe0 chore(plan): T-9 and T-13 done in 1.14.0; T-14, and a note on T-2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:58:36 +02:00
jpmschweitzerandClaude Opus 5.5 44c5facc65 chore(release): 1.14.0 — archive and trash per mailbox, lookup by message number
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:58:17 +02:00
jpmschweitzerandClaude Sonnet 5 e25a3ab4f4 docs(changelog): record T-13/T-9 — archive and trash a mailbox entry, lookup by message number
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 22:55:00 +02:00
jpmschweitzerandClaude Sonnet 5 7c67a4a46b feat(scribe): archive, trash and look up a mailbox entry by message number (T-13/T-9)
The user wants a way to get rid of a message that is no longer useful. A
message is stored once and shared by every recipient; `history` is how
agents rebuild context (D-66), so deleting the message itself would take
it from every other reader. Deletion is therefore per mailbox entry
(D-70): `archive` (kept, out of the default view) and `trash` (out of the
mailbox, restorable). True erasure of content stays `redact` — not this
round.

Migration 009 (down_revision="008") adds
scribe_mailbox_entries.archived_at and trashed_at — independent nullable
timestamps, mirroring book_records.archived_at/trashed_at (migration
005). No data step: every existing entry is neither.

PATCH /scribe/mailbox/{entry_id} takes `archived` and `trashed` booleans
beside `read` (true sets the timestamp if not already set, false clears
it); the response carries both. GET /scribe/mailbox takes `view`: `inbox`
(default, neither archived nor trashed), `archive` (archived and not
trashed), `trash` (trashed, regardless of archived_at — trashing hides an
entry from the archive view too), `all` (no filter on either column).
`find` now excludes trashed entries (archived ones still match).

T-9: GET /scribe/mailbox?message_id=N is a direct lookup, not a filtered
list — the unique constraint on (address, message_id) guarantees zero or
one, in any view, ignoring unread/tag/before_id entirely. That is the
point: a client that already knows the message number never pages the
unread-first/id-descending listing to find it.

Judgement call: the listing's own cursor (`before_id`, a single `id`
comparison) still does not match its two-key sort (unread-first, then id
descending) — the pre-existing gap the dossier flagged as optional to
fix. Left unchanged: T-9's message_id lookup is the actual answer to "a
client pages to find one," and reworking the general cursor is a larger,
separate concern (which field(s) a keyset cursor needs) that risks the
already-passing test_mailbox_lists_unread_first for no requirement this
round asked for.

Judgement call: archived_at and trashed_at are independent timestamps,
never cross-clearing each other. Trashing an already-archived entry does
not clear archived_at, so restoring it (trashed=false) returns it to the
archive view, not the inbox — the view filter's own trash-takes-priority
rule is what keeps it out of view=archive while trashed, not a write-time
side effect.

Tests: 12 new (6 archive/trash flow + 4 message_id lookup + 2 migration
round-trip). Full suite 554 -> 566 passed.

Mutation checks (Edit, reverted after each):
- view=inbox dropped its trashed_at filter ->
  test_trash_hides_from_inbox_archive_and_find_shows_in_trash failed
  (assert 1 == 0).
- message_id lookup dropped the caller-address filter ->
  test_message_id_answers_nothing_for_a_message_not_in_their_mailbox
  failed (assert 1 == 0).
- trash updated every mailbox entry for the message instead of the
  caller's own row ->
  test_trashing_one_recipients_entry_leaves_the_others_untouched failed
  (assert 0 == 1).
All three reverted; full suite re-verified green (566 passed).

Migration 008 -> 009 -> 008 -> 009 round-tripped against core_api_test,
both manually (alembic CLI) and in
TestMigration009RoundTrip. Production stays at 008 — every alembic
invocation derived POSTGRES_* from TEST_DATABASE_URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 22:54:35 +02:00
jpmschweitzerandClaude Opus 5.5 0bba3baf32 chore(plan): T-13 — archive and trash a message in your own mailbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:35:21 +02:00
jpmschweitzerandClaude Opus 5.5 c21c03c289 chore(plan): T-11 done — live in 1.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:16:05 +02:00
jpmschweitzerandClaude Opus 5.5 fca545aa49 chore(plan): T-12 — the test database URL leaks through pytest -v
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:08:36 +02:00
jpmschweitzerandClaude Opus 5.5 dbb924da59 chore(release): 1.13.0 — a default address on each consumer grant
Build and Push / release (push) Successful in 1s
Build and Push / build (push) Successful in 13s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:08:36 +02:00
jpmschweitzerandClaude Sonnet 5 ff54fcc94d docs(changelog): record T-11 — a default address on each consumer grant
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 22:06:18 +02:00
jpmschweitzerandClaude Sonnet 5 80034bbacf feat(tenants): add a default address on each consumer grant (T-11)
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 a fallback: the Office uses it when a call names none
and has no session binding (tatlock-office T-5, in a parallel round,
reads it).

Migration 008 (down_revision="007") adds
tenant_consumers.default_address — nullable varchar(63) — and 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. scribe_addresses already carries both a
primary key and a named unique constraint on (owner_tenant, name)
(migration 006), so the composite FK has something to bind to; Postgres's
default MATCH SIMPLE means a NULL default_address never triggers the
check, which is the state every grant but one is left in. The data step
sets jpmschweitzer/office-claude -> claude-ai only if that address exists
at upgrade time (true in production, false in core_api_test, where no
scribe_addresses are seeded) — an UPDATE ... WHERE EXISTS, not a bare
UPDATE, so it is a no-op rather than a failure when absent.
llm_tester/office-claude is untouched and stays null. Downgrade drops the
FK then the column.

TenantConsumer (models.py) gains the column and a matching
ForeignKeyConstraint. TenantsService.get_me now returns a third element,
the caller's own consumer's default_address (looked up only when
caller.consumer is not None — a user-kind caller carries no consumer, so
there is no grant row to hold a default for it) or None. GET /tenants/me
answers it; GET /tenants and its TenantSummary schema are unchanged.

Tests: TestMigration008RoundTrip/ForeignKey/DataStep added to
tests/test_tenants_migration.py (round-trip 007<->008, the FK existing
and refusing an unknown address while allowing null, the data step's
no-op and positive cases). tests/conftest.py gains a
default_address_grant fixture (registers a throwaway address, points a
grant at it, reverses both on teardown). tests/test_tenants.py gains
TestMeDefaultAddress (an office caller with/without a default, a user
caller always null).

Friction found and fixed in the same commit: the new composite FK made
Postgres refuse tests/test_scribe.py's existing clean_scribe_tables
TRUNCATE ("cannot truncate a table referenced in a foreign key
constraint") — the restriction is on the constraint's existence, not on
whether any row's default_address is actually non-null. tenant_consumers
joins that TRUNCATE; CASCADE was rejected because it would silently drop
every grant rather than only addresses, so the four grants
(jpmschweitzer/llm_tester x office-claude/office-gemini) are reinserted
in the same fixture, right after.

Mutation-checked: forcing default_address to null in the controller
fails exactly test_office_caller_with_a_default_sees_it, leaving the
without-a-default and user-caller tests green. Disabling the FK's
creation (and, symmetrically, its drop in downgrade — to keep the
round-trip self-consistent against the live core_api_test schema) fails
test_fk_exists and test_fk_refuses_a_default_naming_an_address_that_does_not_exist,
leaving test_fk_allows_a_null_default_regardless_of_tenant green. Both
mutations reverted; the full suite re-verified green (543 + 11 = 554
passed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 22:05:46 +02:00
jpmschweitzerandClaude Opus 5.5 008996e030 chore(plan): T-11 — a default address on each consumer grant
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:38:18 +02:00
jpmschweitzerandClaude Opus 5.5 d1641f1480 chore(plan): T-10 — Scribe messages number per tenant
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:12:23 +02:00
jpmschweitzerandClaude Opus 5.5 1c4cfc4f1b chore(plan): T-9 — the mailbox answers by message number
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:08:02 +02:00
jpmschweitzerandClaude Opus 5.5 87481a016a chore(plan): T-1 and T-8 done — live in 1.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:48:04 +02:00
21 changed files with 1541 additions and 44 deletions
+71
View File
@@ -91,3 +91,74 @@ 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;
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;
+7
View File
@@ -6,3 +6,10 @@ 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 ('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 ('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;
+105
View File
@@ -94,3 +94,108 @@ 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;
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;
+47
View File
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.15.0] - 2026-10-19
### Added
- **A `feedback` message to `tower-control` may have no sender** (`scribe_messages.from_identity`
nullable, migration 010, core-api T-15 / workspace D-70) — the failure channel must not depend
on the thing most likely to break. `POST /scribe/messages` accepts a missing
`X-Office-Identity` for exactly one shape: an office caller, `to` is `["tower-control"]` alone,
and `tags` contains `feedback`; stored with `from_identity` null and `from_consumer` the
caller's consumer. Every other send without an identity is still 400 `identity_required`, and
a `user`-kind caller gains nothing from the exemption. `MessageResponse.from_identity` is now
optional, so `mailbox`, `history`, `find` and the `message_id` lookup all answer such a message
with a null sender.
## [1.14.0] - 2026-10-19
### Added
- **Archive and trash a message in your own mailbox** (`scribe_mailbox_entries.archived_at`
and `trashed_at`, migration 009, core-api T-13) — deletion is per mailbox entry, never per
message, since a message is retained and shared by every recipient (D-66/D-70). `PATCH
/scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read` (true sets
the timestamp, false clears it); the response carries both states. `GET /scribe/mailbox`
takes `view`: `inbox` (default, neither archived nor trashed), `archive` (archived, not
trashed), `trash` (trashed, regardless of `archived_at`), `all`. `find` now excludes trashed
entries.
- **Look up a mailbox entry by message number** (core-api T-9) — `GET
/scribe/mailbox?message_id=N` answers the caller's own entry for that message, zero or one,
in any view, bypassing `unread`/`tag`/`before_id` so no client pages the unread-first/
id-descending listing to find one.
## [1.13.0] - 2026-10-19
### Added
- **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
### Added
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "core-api"
version = "1.12.0"
version = "1.15.0"
description = "Core Code API - Infrastructure management and tools API"
readme = "README.md"
requires-python = ">=3.12"
+36 -6
View File
@@ -2,10 +2,15 @@
Scribe Controller
The Scribe's messaging verbs (dossier core-api T-5): register, list
(addresses), send, mailbox, history, find, mark, tag/untag. Every route is
behind an Office or user caller (T-1/T-4); every query filters on the
caller's tenant (D-73) — an address or message in another tenant answers
exactly like one that does not exist.
(addresses), send, mailbox, history, find, mark, tag/untag, archive/trash
a mailbox entry and look one up by message number (core-api T-13/T-9).
Every route is behind an Office or user caller (T-1/T-4); every query
filters on the caller's tenant (D-73) — an address or message in another
tenant answers exactly like one that does not exist. `send` is the one
write that does not require an identity at the dependency layer: core-api
T-15 / D-70 lets an office caller's `feedback` to `tower-control` alone go
with no sender, so ScribeService.send_message enforces identity_required
itself, after seeing the body.
"""
from typing import Optional
@@ -20,6 +25,7 @@ from src.domains.scribe.schemas import (
MailboxEntryResponse,
MailboxListResponse,
MailboxPatchRequest,
MailboxView,
MessageResponse,
MessageSendRequest,
)
@@ -33,6 +39,13 @@ logger = get_logger(__name__)
require_scribe_reader = require_caller("office", "user")
require_scribe_writer = require_caller("office", "user", identity_required=True)
# `/messages` POST is not `identity_required` at this layer: core-api T-15 /
# workspace D-70 lets one shape through with no identity (an office
# caller's `feedback` to `tower-control` alone), and that decision needs
# the request body, which a caller dependency never sees. ScribeService
# .send_message is the single place that enforces identity_required for
# every other shape — see its docstring.
require_scribe_sender = require_caller("office", "user")
def _address_response(address, owned: bool) -> AddressResponse:
@@ -54,6 +67,8 @@ def _entry_response(entry, message) -> MailboxEntryResponse:
id=entry.id,
address=entry.address,
read_at=entry.read_at,
archived_at=entry.archived_at,
trashed_at=entry.trashed_at,
tags=entry.tags,
created_at=entry.created_at,
message=_message_response(message),
@@ -123,7 +138,7 @@ class ScribeController(BaseController):
)
async def send_message(
data: MessageSendRequest,
caller: Caller = Depends(require_scribe_writer),
caller: Caller = Depends(require_scribe_sender),
session: AsyncSession = Depends(get_async_session),
):
try:
@@ -171,16 +186,31 @@ class ScribeController(BaseController):
summary="The caller identity's mailbox, unread first",
)
async def get_mailbox(
view: MailboxView = Query(
"inbox", description="inbox (default, neither archived nor trashed), archive, trash, all"
),
unread: bool = Query(False),
tag: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
before_id: Optional[int] = Query(None),
message_id: Optional[int] = Query(
None,
description="look up the caller's own entry for this message number directly, "
"in any view, ignoring unread/tag/before_id (core-api T-9)",
),
caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
results, total = await service.get_mailbox(
session, caller, unread=unread, tag=tag, limit=limit, before_id=before_id
session,
caller,
view=view,
unread=unread,
tag=tag,
limit=limit,
before_id=before_id,
message_id=message_id,
)
except ScribeError as error:
raise HTTPException(
+12 -1
View File
@@ -90,7 +90,12 @@ class ScribeMessage(Base):
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False, index=True
)
topics: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
from_identity: Mapped[str] = mapped_column(String(63), nullable=False)
# Nullable since migration 010 (core-api T-15 / D-70): a `feedback`
# message to `tower-control` alone may ship with no sender — the one
# shape the failure channel must not depend on an address for. The
# service layer (ScribeService._is_exempt_feedback) is what actually
# narrows which sends may leave this null.
from_identity: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
from_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
body: Mapped[str] = mapped_column(Text, nullable=False)
@@ -134,6 +139,12 @@ class ScribeMailboxEntry(Base):
Integer, ForeignKey("scribe_messages.id"), nullable=False
)
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Migration 009 (core-api T-13): deletion is per mailbox entry, never
# per message (D-66/D-70) — independent nullable timestamps, mirroring
# book_records.archived_at/trashed_at. The service layer's view=trash
# takes priority over view=archive when both are set.
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
trashed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
+18 -5
View File
@@ -3,17 +3,20 @@ Scribe Domain Schemas
Pydantic request/response payloads for the Scribe (D-66, D-68, D-73). This
round ships `register`, `list` (addresses), `send`, `mailbox`, `history`,
`find`, `mark`, `tag`/`untag` — groups, subscriptions, the notification
matrix and delivery are later work (dossier core-api T-5).
`find`, `mark`, `tag`/`untag`, archive/trash a mailbox entry and look one
up by message number (core-api T-13/T-9) — groups, subscriptions, the
notification matrix and delivery are later work (dossier core-api T-5).
"""
from datetime import datetime
from typing import List, Optional
from typing import List, Literal, Optional
from pydantic import Field
from src.domains.scribe.models import ADDRESS_NAME_PATTERN
from src.shared.base import BaseSchema
MailboxView = Literal["inbox", "archive", "trash", "all"]
class AddressRegisterRequest(BaseSchema):
"""`register` — the name only; owner (tenant, consumer) comes from the caller."""
@@ -46,7 +49,10 @@ class MessageSendRequest(BaseSchema):
class MessageResponse(BaseSchema):
id: int
topics: List[str]
from_identity: str
# Optional since core-api T-15 / D-70: a sender-less `feedback` message
# to `tower-control` answers with from_identity null, everywhere this
# schema is used (mailbox, history, find, message_id lookup).
from_identity: Optional[str]
from_consumer: Optional[str]
subject: Optional[str]
body: str
@@ -61,6 +67,8 @@ class MailboxEntryResponse(BaseSchema):
id: int
address: str
read_at: Optional[datetime]
archived_at: Optional[datetime]
trashed_at: Optional[datetime]
tags: List[str]
created_at: datetime
message: MessageResponse
@@ -78,8 +86,13 @@ class HistoryResponse(BaseSchema):
class MailboxPatchRequest(BaseSchema):
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share one route."""
"""`mark` (`read`), `archive`/`unarchive`, `trash`/`restore` and
`tag`/`untag` (`add_tags`/`remove_tags`) share one route. `archived`/
`trashed` are independent: `true` sets the timestamp (if not already
set), `false` clears it (workspace core-api T-13)."""
read: Optional[bool] = None
archived: Optional[bool] = None
trashed: Optional[bool] = None
add_tags: Optional[List[str]] = None
remove_tags: Optional[List[str]] = None
+110 -17
View File
@@ -62,6 +62,19 @@ class ScribeService:
except TenantError as error:
raise ScribeError(error.code, error.status_code) from error
def _is_exempt_feedback(self, caller: Caller, recipients: List[str], tags: List[str]) -> bool:
"""core-api T-15 / workspace D-70 ("`feedback` needs no address"):
the one shape that may ship with no sender is an office caller's
`feedback` to `tower-control` alone — nothing broader. `recipients`
and `tags` are the de-duplicated lists `send_message` already
computed, so a second or different recipient, or a message missing
the `feedback` tag, never qualifies."""
return (
caller.kind == "office"
and recipients == ["tower-control"]
and "feedback" in tags
)
def _validate_address_name(self, name: str) -> None:
"""Beyond the schema's regex (shape only): not `group-*`, not reserved.
Mirrors src/domains/auth/callers.py's `_validate_identity` rule —
@@ -142,14 +155,38 @@ class ScribeService:
async def send_message(
self, session: AsyncSession, data: MessageSendRequest, caller: Caller
) -> ScribeMessage:
"""`send` — `from` is the caller's own identity, never the body."""
"""`send` — `from` is the caller's own identity, never the body.
core-api T-15 / workspace D-70: `require_scribe_sender` (the
controller's dependency for this route) no longer enforces
`identity_required` itself, because that decision depends on the
body (`to`, `tags`), which a caller dependency cannot see. This
method is therefore the single place that enforces it: a missing
identity (`caller.identity == ""`, which only an office caller with
no `X-Office-Identity` header can produce) is allowed through
exactly for `_is_exempt_feedback`'s shape, and is still
`identity_required` for every other send — the tenant check stays
on this path too (no sender is not no tenant)."""
tenant = await self._require_tenant(session, caller)
from_address = await self._get_address(session, tenant, caller.identity)
if from_address is None or from_address.owner_consumer != caller.consumer:
raise ScribeError("identity_not_owned", 403)
recipients = list(dict.fromkeys(data.to)) # de-dup, keep order
tags = list(dict.fromkeys(data.tags))
no_identity = caller.identity == ""
exempt = no_identity and self._is_exempt_feedback(caller, recipients, tags)
if no_identity and not exempt:
raise ScribeError("identity_required", 400)
if exempt:
from_identity = None
from_consumer = caller.consumer
else:
from_address = await self._get_address(session, tenant, caller.identity)
if from_address is None or from_address.owner_consumer != caller.consumer:
raise ScribeError("identity_not_owned", 403)
from_identity = caller.identity
from_consumer = caller.consumer
found_stmt = select(ScribeAddress.name).where(
ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients)
)
@@ -163,13 +200,11 @@ class ScribeService:
if reply_target is None or reply_target.owner_tenant != tenant:
raise ScribeError("not_found", 404)
tags = list(dict.fromkeys(data.tags))
message = ScribeMessage(
owner_tenant=tenant,
topics=recipients,
from_identity=caller.identity,
from_consumer=caller.consumer,
from_identity=from_identity,
from_consumer=from_consumer,
subject=data.subject,
body=data.body,
tags=tags,
@@ -187,7 +222,8 @@ class ScribeService:
await session.refresh(message)
logger.info(
f"Scribe message sent: id={message.id} from={caller.identity} to={recipients}"
f"Scribe message sent: id={message.id} from={from_identity} "
f"from_consumer={from_consumer} to={recipients}"
)
return message
@@ -199,19 +235,57 @@ class ScribeService:
self,
session: AsyncSession,
caller: Caller,
view: str = "inbox",
unread: Optional[bool] = None,
tag: Optional[str] = None,
limit: int = 50,
before_id: Optional[int] = None,
message_id: Optional[int] = None,
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
"""`mailbox` — the caller's identity's entries, unread first."""
"""`mailbox` — the caller's identity's entries, unread first.
`message_id`, when given, is a direct lookup rather than a filtered
list: zero or one entry (the unique constraint on
(address, message_id) guarantees at most one), in any view — it
ignores `view`/`unread`/`tag`/`before_id` entirely, which is the
point (core-api T-9): a client that already knows the message
number never pages the unread-first/id-descending listing to find
it.
Otherwise `view` selects which of the independent `archived_at`/
`trashed_at` states to show: `inbox` (default) is neither,
`archive` is archived and not trashed, `trash` is trashed
(regardless of archived_at — trashing hides an entry from the
archive view too), `all` applies no filter on either column.
"""
tenant = await self._require_tenant(session, caller)
await self._require_owned_identity(session, caller, tenant)
if message_id is not None:
lookup_query = select(ScribeMailboxEntry).where(
ScribeMailboxEntry.owner_tenant == tenant,
ScribeMailboxEntry.address == caller.identity,
ScribeMailboxEntry.message_id == message_id,
)
entries = list((await session.execute(lookup_query)).scalars().all())
messages_by_id = await self._messages_for_entries(session, entries)
results = [(entry, messages_by_id[entry.message_id]) for entry in entries]
return results, len(results)
conditions = [
ScribeMailboxEntry.owner_tenant == tenant,
ScribeMailboxEntry.address == caller.identity,
]
if view == "archive":
conditions.append(ScribeMailboxEntry.archived_at.isnot(None))
conditions.append(ScribeMailboxEntry.trashed_at.is_(None))
elif view == "trash":
conditions.append(ScribeMailboxEntry.trashed_at.isnot(None))
elif view == "all":
pass
else: # "inbox" (default)
conditions.append(ScribeMailboxEntry.archived_at.is_(None))
conditions.append(ScribeMailboxEntry.trashed_at.is_(None))
if unread:
conditions.append(ScribeMailboxEntry.read_at.is_(None))
if tag is not None:
@@ -236,7 +310,10 @@ class ScribeService:
async def find_messages(
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
"""`find` — full text over messages in the caller identity's mailbox."""
"""`find` — full text over messages in the caller identity's
mailbox, every entry that is not trashed (core-api T-13: trash
takes an entry out of `find` the same way it takes it out of
`inbox` and `archive`); archived entries still match."""
tenant = await self._require_tenant(session, caller)
await self._require_owned_identity(session, caller, tenant)
@@ -247,6 +324,7 @@ class ScribeService:
.where(
ScribeMailboxEntry.owner_tenant == tenant,
ScribeMailboxEntry.address == caller.identity,
ScribeMailboxEntry.trashed_at.is_(None),
ScribeMessage.search.op("@@")(tsquery),
)
)
@@ -302,11 +380,16 @@ class ScribeService:
async def patch_mailbox_entry(
self, session: AsyncSession, entry_id: int, data: MailboxPatchRequest, caller: Caller
) -> Tuple[ScribeMailboxEntry, ScribeMessage]:
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share
this route. An entry outside the caller's tenant, or addressed to
an identity the caller is not reading as, answers 404 not_found —
indistinguishable from a nonexistent entry, so an id never confirms
someone else's mail exists."""
"""`mark` (`read`), `archive`/`unarchive`, `trash`/`restore` and
`tag`/`untag` (`add_tags`/`remove_tags`) share this route. An entry
outside the caller's tenant, or addressed to an identity the caller
is not reading as, answers 404 not_found — indistinguishable from a
nonexistent entry, so an id never confirms someone else's mail
exists. `archived`/`trashed` are independent timestamps (core-api
T-13): `true` sets the one not already set, `false` clears it —
trashing does not clear `archived_at`, so restoring a message that
was archived before it was trashed returns it to the archive view,
not the inbox."""
tenant = await self._require_tenant(session, caller)
entry = await session.get(ScribeMailboxEntry, entry_id)
@@ -318,6 +401,16 @@ class ScribeService:
elif data.read is False:
entry.read_at = None
if data.archived is True and entry.archived_at is None:
entry.archived_at = datetime.now(timezone.utc)
elif data.archived is False:
entry.archived_at = None
if data.trashed is True and entry.trashed_at is None:
entry.trashed_at = datetime.now(timezone.utc)
elif data.trashed is False:
entry.trashed_at = None
if data.add_tags:
current = set(entry.tags)
entry.tags = list(entry.tags) + [t for t in data.add_tags if t not in current]
+2 -1
View File
@@ -51,7 +51,7 @@ class TenantsController(BaseController):
session: AsyncSession = Depends(get_async_session),
):
try:
tenant, consumers = await service.get_me(session, caller)
tenant, consumers, default_address = await service.get_me(session, caller)
except TenantError as error:
raise HTTPException(
status_code=error.status_code, detail={"code": error.code}
@@ -62,6 +62,7 @@ class TenantsController(BaseController):
kind=tenant.kind,
status=tenant.status,
consumers=consumers,
default_address=default_address,
)
# =====================================================================
+29 -2
View File
@@ -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
consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
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 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 src.shared.database import Base
@@ -55,14 +70,26 @@ class Tenant(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"
__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(
String(63), ForeignKey("tenants.id"), 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(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
+7 -2
View File
@@ -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 T-195).
"""
from typing import List
from typing import List, Optional
from src.shared.base import BaseSchema
@@ -21,9 +21,14 @@ class TenantSummary(BaseSchema):
class TenantMeResponse(TenantSummary):
"""`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]
default_address: Optional[str] = None
class TenantListResponse(BaseSchema):
+19 -3
View File
@@ -81,9 +81,16 @@ class TenantsService:
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
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 = await session.get(Tenant, tenant_id)
@@ -93,7 +100,16 @@ class TenantsService:
.order_by(TenantConsumer.consumer)
)
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]:
"""
+86 -4
View File
@@ -225,13 +225,36 @@ async def scribe_session(scribe_schema: str):
@pytest.fixture
async def clean_scribe_tables(scribe_session):
"""Scribe service methods commit internally (same reason as Book's
clean_book_tables). TRUNCATE all three tables together so Postgres
resolves the FK order (mailbox_entries -> messages, messages'
self-referencing reply_to) across the one statement."""
clean_book_tables). TRUNCATE all tables together so Postgres resolves
the FK order (mailbox_entries -> messages, messages' self-referencing
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
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()
yield scribe_session
@@ -287,3 +310,62 @@ async def suspended_tenant(test_database_url: str):
await session.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id})
await session.commit()
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()
+425
View File
@@ -7,6 +7,8 @@ overridden to the test database for every test, building a fresh
engine/session per call because TestClient drives the ASGI app on a
different event loop than pytest-asyncio's fixture setup.
"""
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
@@ -68,6 +70,17 @@ def office_headers(identity: str, consumer: str, tenant: str = TENANT) -> dict:
}
def office_headers_no_identity(consumer: str, tenant: str = TENANT) -> dict:
"""No `X-Office-Identity` at all — core-api T-15's one exemption (an
office caller's `feedback` to `tower-control` alone) is reachable only
through this shape; every other write still needs the header."""
return {
"Authorization": f"Bearer {OFFICE_KEY}",
"X-Office-Consumer": consumer,
"X-Office-Tenant": tenant,
}
def register(client, identity: str, consumer: str, tenant: str = TENANT):
return client.post(
"/scribe/addresses",
@@ -403,6 +416,121 @@ class TestSendValidation:
assert reply.json()["detail"]["code"] == "not_found"
# =============================================================================
# `feedback` needs no address (core-api T-15 / workspace D-70): the one
# shape that may ship with no sender is an office caller's `feedback` to
# `tower-control` alone — nothing broader.
# =============================================================================
class TestFeedbackWithoutIdentity:
def test_feedback_to_tower_control_alone_with_no_identity_succeeds(self, client):
register(client, "tower-control", "office-gemini")
sent = client.post(
"/scribe/messages",
json={
"to": ["tower-control"],
"body": "the find verb has no limit guard",
"tags": ["feedback"],
},
headers=office_headers_no_identity("office-claude"),
)
assert sent.status_code == 201
message = sent.json()
assert message["from_identity"] is None
assert message["from_consumer"] == "office-claude"
assert message["topics"] == ["tower-control"]
assert message["tags"] == ["feedback"]
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox["total"] == 1
assert mailbox["entries"][0]["message"]["from_identity"] is None
assert mailbox["entries"][0]["message"]["id"] == message["id"]
history = client.get(
"/scribe/addresses/tower-control/history",
headers=office_headers("tower-control", "office-gemini"),
).json()
assert history["messages"][0]["from_identity"] is None
found = client.get(
"/scribe/messages",
params={"q": "limit"},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["from_identity"] is None
def test_feedback_with_no_identity_still_needs_a_known_tenant(self, client):
"""No sender is not no tenant (the brief): `_require_tenant` keeps
running on this path, ahead of the identity decision."""
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
headers=office_headers_no_identity("office-claude", tenant="no-such-tenant"),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "tenant_unknown"
def test_feedback_with_no_identity_still_needs_a_granted_consumer(self, client):
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
headers=office_headers_no_identity("office-ungranted"),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "consumer_not_granted"
@pytest.mark.parametrize(
"to,tags",
[
(["tower-control", "jeroen"], ["feedback"]), # a second recipient
(["jeroen"], ["feedback"]), # another recipient, not tower-control
(["tower-control"], []), # no feedback tag
(["tower-control"], ["urgent"]), # tagged, but not feedback
],
ids=[
"second-recipient",
"another-recipient",
"no-feedback-tag",
"tagged-but-not-feedback",
],
)
def test_every_broader_shape_with_no_identity_is_still_400(self, client, to, tags):
response = client.post(
"/scribe/messages",
json={"to": to, "body": "x", "tags": tags},
headers=office_headers_no_identity("office-claude"),
)
assert response.status_code == 400
assert response.json()["detail"]["code"] == "identity_required"
def test_user_kind_caller_gains_nothing_from_the_exemption(self, client):
"""A user-kind caller's identity is never empty (callers.py always
falls back to a name, never ""), so it never reaches the
no-identity branch at all: sending this exact shape is unaffected
by T-15 and still needs an owned address, same as before. Not 400
identity_required — that code names an office caller resolved with
none; this is 403 identity_not_owned, because "jeroen" was never
registered under this (consumer-less) user identity."""
register(client, "tower-control", "office-gemini")
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
mock_get_current_user.return_value = {
"preferred_username": "jeroen",
"tatlock_user": TENANT,
}
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
headers={"Authorization": "Bearer some-jwt"},
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "identity_not_owned"
# =============================================================================
# Isolation (dossier verification #3): the hard tenant boundary, both
# directions — every cross-tenant answer is indistinguishable from "does
@@ -618,3 +746,300 @@ class TestTenantRegistryEnforcement:
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant=TENANT)
)
assert response.status_code == 200
# =============================================================================
# Archive and trash a mailbox entry (core-api T-13 / D-66, D-70): deletion
# is per mailbox entry, never per message — the message stays in `history`
# and another recipient's entry is untouched throughout.
# =============================================================================
def _send_and_get_entry_id(client, body: str, to: str = "tower-control") -> tuple:
"""Register the usual pair if needed by the caller, send one message,
and return (message, entry_id) for the recipient's own mailbox entry."""
sent = client.post(
"/scribe/messages",
json={"to": [to], "body": body},
headers=office_headers("jeroen", "office-claude"),
).json()
entry_id = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()["entries"][0]["id"]
return sent, entry_id
class TestArchiveAndTrash:
def test_archive_hides_from_inbox_and_shows_in_archive_view(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
_, entry_id = _send_and_get_entry_id(client, "archive me")
archived = client.patch(
f"/scribe/mailbox/{entry_id}",
json={"archived": True},
headers=office_headers("tower-control", "office-gemini"),
)
assert archived.status_code == 200
assert archived.json()["archived_at"] is not None
assert archived.json()["trashed_at"] is None
inbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert inbox["total"] == 0
archive_view = client.get(
"/scribe/mailbox",
params={"view": "archive"},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert archive_view["total"] == 1
assert archive_view["entries"][0]["id"] == entry_id
def test_unarchive_brings_it_back_to_inbox(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
_, entry_id = _send_and_get_entry_id(client, "archive then unarchive")
client.patch(
f"/scribe/mailbox/{entry_id}",
json={"archived": True},
headers=office_headers("tower-control", "office-gemini"),
)
unarchived = client.patch(
f"/scribe/mailbox/{entry_id}",
json={"archived": False},
headers=office_headers("tower-control", "office-gemini"),
)
assert unarchived.json()["archived_at"] is None
inbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert inbox["total"] == 1
assert inbox["entries"][0]["id"] == entry_id
def test_trash_hides_from_inbox_archive_and_find_shows_in_trash(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
_, entry_id = _send_and_get_entry_id(client, "trash-findable-term")
trashed = client.patch(
f"/scribe/mailbox/{entry_id}",
json={"trashed": True},
headers=office_headers("tower-control", "office-gemini"),
)
assert trashed.status_code == 200
assert trashed.json()["trashed_at"] is not None
inbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert inbox["total"] == 0
archive_view = client.get(
"/scribe/mailbox",
params={"view": "archive"},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert archive_view["total"] == 0
found = client.get(
"/scribe/messages",
params={"q": "trash-findable-term"},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert found["total"] == 0
trash_view = client.get(
"/scribe/mailbox",
params={"view": "trash"},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert trash_view["total"] == 1
assert trash_view["entries"][0]["id"] == entry_id
def test_restore_brings_it_back_to_inbox(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
_, entry_id = _send_and_get_entry_id(client, "trash then restore")
client.patch(
f"/scribe/mailbox/{entry_id}",
json={"trashed": True},
headers=office_headers("tower-control", "office-gemini"),
)
restored = client.patch(
f"/scribe/mailbox/{entry_id}",
json={"trashed": False},
headers=office_headers("tower-control", "office-gemini"),
)
assert restored.json()["trashed_at"] is None
inbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert inbox["total"] == 1
assert inbox["entries"][0]["id"] == entry_id
def test_message_stays_in_history_throughout_archive_and_trash(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
message, entry_id = _send_and_get_entry_id(client, "history survives")
for payload in ({"archived": True}, {"trashed": True}):
client.patch(
f"/scribe/mailbox/{entry_id}",
json=payload,
headers=office_headers("tower-control", "office-gemini"),
)
history = client.get(
"/scribe/addresses/tower-control/history",
headers=office_headers("tower-control", "office-gemini"),
).json()
assert [m["id"] for m in history["messages"]] == [message["id"]]
def test_trashing_one_recipients_entry_leaves_the_others_untouched(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
register(client, "jeroen-2", "office-claude")
client.post(
"/scribe/messages",
json={"to": ["tower-control", "jeroen-2"], "body": "broadcast"},
headers=office_headers("jeroen", "office-claude"),
)
tower_entry_id = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()["entries"][0]["id"]
client.patch(
f"/scribe/mailbox/{tower_entry_id}",
json={"trashed": True},
headers=office_headers("tower-control", "office-gemini"),
)
other_inbox = client.get(
"/scribe/mailbox", headers=office_headers("jeroen-2", "office-claude")
).json()
assert other_inbox["total"] == 1
assert other_inbox["entries"][0]["trashed_at"] is None
# =============================================================================
# Look an entry up by message number (core-api T-9): zero or one, in any
# view, no paging required.
# =============================================================================
class TestMailboxMessageIdLookup:
def test_message_id_finds_the_entry_in_any_view(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
message, entry_id = _send_and_get_entry_id(client, "locate me")
client.patch(
f"/scribe/mailbox/{entry_id}",
json={"trashed": True},
headers=office_headers("tower-control", "office-gemini"),
)
found = client.get(
"/scribe/mailbox",
params={"message_id": message["id"]},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert found["total"] == 1
assert found["entries"][0]["id"] == entry_id
assert found["entries"][0]["trashed_at"] is not None
def test_message_id_answers_nothing_for_a_message_not_in_their_mailbox(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "not jeroen's"},
headers=office_headers("jeroen", "office-claude"),
).json()
found = client.get(
"/scribe/mailbox",
params={"message_id": sent["id"]},
headers=office_headers("jeroen", "office-claude"),
).json()
assert found["total"] == 0
assert found["entries"] == []
def test_message_id_answers_nothing_across_tenants(self, client):
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "tenant-scoped"},
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
).json()
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
found = client.get(
"/scribe/mailbox",
params={"message_id": sent["id"]},
headers=office_headers("tower-control", "office-gemini", OTHER_TENANT),
).json()
assert found["total"] == 0
def test_message_id_finds_a_read_entry_past_the_first_page_in_one_call(self, client):
"""dossier verification #4: today's listing sorts unread-first,
then by id descending, behind a single before_id cursor. Seed one
old entry that stays unread (so it sorts first regardless of its
low id) ahead of 51 read ones (one more than the default page size
of 50) — the oldest read entry (lowest id among them) sorts last,
past the first page. `message_id` answers it in one call, with no
`before_id` paging."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
old_unread = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "old and unread"},
headers=office_headers("jeroen", "office-claude"),
).json()
read_message_ids = []
for i in range(51):
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": f"read {i}"},
headers=office_headers("jeroen", "office-claude"),
).json()
read_message_ids.append(sent["id"])
all_entries = client.get(
"/scribe/mailbox",
params={"view": "all", "limit": 200},
headers=office_headers("tower-control", "office-gemini"),
).json()["entries"]
for entry in all_entries:
if entry["message"]["id"] != old_unread["id"]:
client.patch(
f"/scribe/mailbox/{entry['id']}",
json={"read": True},
headers=office_headers("tower-control", "office-gemini"),
)
# The oldest (lowest-id) read message: last among the reads when
# sorted id-descending, so page 1 (the default limit of 50) never
# reaches it — plain listing would need a second before_id call.
target_message_id = read_message_ids[0]
page_one = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert target_message_id not in {e["message"]["id"] for e in page_one["entries"]}
found = client.get(
"/scribe/mailbox",
params={"message_id": target_message_id},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["id"] == target_message_id
+153 -1
View File
@@ -1,5 +1,5 @@
"""
Tests for Scribe migration 006.
Tests for Scribe migrations 006, 009 and 010.
Runs against `core_api_test` only (see conftest.py — skip when
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
@@ -27,6 +27,74 @@ async def _table_exists(url: str, table: str) -> bool:
await engine.dispose()
async def _is_nullable(url: str, table: str, column: str) -> bool:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
result = await conn.execute(
text(
"SELECT is_nullable FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() == "YES"
finally:
await engine.dispose()
async def _clear_scribe_messages(url: str) -> None:
"""Truncate just the two row-bearing Scribe tables that 010's downgrade
inspects — never `scribe_addresses`/`tenant_consumers`, which other
modules' seeded grants depend on. Self-contained so this file's
row-level checks never depend on what another test module's data left
behind (there is no FK from either table to `scribe_addresses`)."""
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.begin() as conn:
await conn.execute(text("TRUNCATE scribe_mailbox_entries, scribe_messages"))
finally:
await engine.dispose()
async def _insert_sender_less_message(url: str) -> None:
"""Insert one scribe_messages row with `from_identity` NULL directly —
bypassing the API, which (even under T-15's exemption) always exists
for the one shape it allows; this proves the migration's own
constraint/refusal, not the service's."""
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO scribe_messages (owner_tenant, topics, from_identity, body) "
"VALUES ('jpmschweitzer', ARRAY['tower-control'], NULL, 'no sender')"
)
)
finally:
await engine.dispose()
async def _columns(url: str, table: str) -> set:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
result = await conn.execute(
text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = :table"
),
{"table": table},
)
return {row[0] for row in result.all()}
finally:
await engine.dispose()
class TestMigration006RoundTrip:
"""Migration 006 must upgrade and downgrade cleanly (dossier verification #1)."""
@@ -58,3 +126,87 @@ class TestMigration006RoundTrip:
assert await _table_exists(test_database_url, "scribe_addresses")
assert await _table_exists(test_database_url, "scribe_messages")
assert await _table_exists(test_database_url, "scribe_mailbox_entries")
class TestMigration009RoundTrip:
"""Migration 009 (core-api T-13) must upgrade and downgrade cleanly —
dossier verification #2: 008 -> 009 -> 008 -> 009."""
@pytest.mark.asyncio
async def test_upgrade_adds_archived_and_trashed_at(self, test_database_url):
result = run_alembic(test_database_url, "upgrade", "head")
assert result.returncode == 0, result.stderr
columns = await _columns(test_database_url, "scribe_mailbox_entries")
assert {"archived_at", "trashed_at"} <= columns
@pytest.mark.asyncio
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
baseline = run_alembic(test_database_url, "upgrade", "head")
assert baseline.returncode == 0, baseline.stderr
down = run_alembic(test_database_url, "downgrade", "008")
assert down.returncode == 0, down.stderr
columns_at_008 = await _columns(test_database_url, "scribe_mailbox_entries")
assert "archived_at" not in columns_at_008
assert "trashed_at" not in columns_at_008
# The table itself, created by 006, must survive a downgrade of
# 009 — proves 009 only adds/drops its own two columns.
assert await _table_exists(test_database_url, "scribe_mailbox_entries")
# Leave the schema at head (009 is head) for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr
columns_at_head = await _columns(test_database_url, "scribe_mailbox_entries")
assert {"archived_at", "trashed_at"} <= columns_at_head
class TestMigration010RoundTrip:
"""Migration 010 (core-api T-15 / D-70) must upgrade and downgrade
cleanly — dossier verification #2: 009 -> 010 -> 009 -> 010 — and its
downgrade must name a decision about a sender-less row rather than
silently inventing one (the brief: refusing is acceptable, fabricating
a sender is not). `_clear_scribe_messages` scopes each test to its own
row state rather than trusting what another test module left behind."""
@pytest.mark.asyncio
async def test_upgrade_makes_from_identity_nullable(self, test_database_url):
result = run_alembic(test_database_url, "upgrade", "head")
assert result.returncode == 0, result.stderr
assert await _is_nullable(test_database_url, "scribe_messages", "from_identity")
@pytest.mark.asyncio
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
baseline = run_alembic(test_database_url, "upgrade", "head")
assert baseline.returncode == 0, baseline.stderr
await _clear_scribe_messages(test_database_url) # no sender-less row in the way
down = run_alembic(test_database_url, "downgrade", "009")
assert down.returncode == 0, down.stderr
assert not await _is_nullable(test_database_url, "scribe_messages", "from_identity")
# Leave the schema at head (010 is head) for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr
assert await _is_nullable(test_database_url, "scribe_messages", "from_identity")
@pytest.mark.asyncio
async def test_downgrade_refuses_while_a_sender_less_row_exists(self, test_database_url):
"""The chosen behaviour, named: the downgrade does not run, and the
column stays nullable — nothing silently invents a sender for the
row it found."""
baseline = run_alembic(test_database_url, "upgrade", "head")
assert baseline.returncode == 0, baseline.stderr
await _clear_scribe_messages(test_database_url)
await _insert_sender_less_message(test_database_url)
down = run_alembic(test_database_url, "downgrade", "009")
assert down.returncode != 0
assert "migration 010 downgrade refused" in (down.stdout + down.stderr)
assert await _is_nullable(test_database_url, "scribe_messages", "from_identity")
# Leave the schema at head, clean, for the rest of the suite.
await _clear_scribe_messages(test_database_url)
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr
+29
View File
@@ -164,6 +164,35 @@ class TestMe:
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:
"""`GET /tenants` — the hard boundary: another tenant's existence is
never revealed to a tenant-bearing caller."""
+195 -1
View File
@@ -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
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
@@ -186,3 +187,196 @@ class TestMigration007ForeignKeys:
await conn.commit()
finally:
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.
up = run_alembic(test_database_url, "upgrade", "008")
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
up = run_alembic(test_database_url, "upgrade", "008")
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')"
)
)
up = run_alembic(test_database_url, "upgrade", "008")
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()