Compare commits

...
13 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 5.5 56f2c532ed chore(release): 1.16.0 — the mailbox after N, the newest number; alembic names its target
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:57:58 +02:00
jpmschweitzerandClaude Opus 5.5 3944e95e80 docs: the suite takes about 200 seconds — give make test five minutes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:57:58 +02:00
jpmschweitzerandClaude Sonnet 5 3026b9214f feat(scribe): mailbox takes since_message_id, answers newest_message_id (T-16)
The Office's one polling call, glance, answers "has anything changed
since I last looked?" with counts; live core-api ignored the
parameters it needed, so glance could not ship.

GET /scribe/mailbox now takes since_message_id=N: only entries whose
message number is above N (strictly — not >=), combined with
view/unread/tag the same way they combine with each other today.
message_id, which already bypasses those filters, keeps bypassing
since_message_id too.

The response gains newest_message_id: the caller's newest message
number across its whole mailbox, every view, regardless of any filter
the call applied; null when the mailbox is empty. It exists because
the unread-first sort can return an older unread entry on a limit=1
page while a newer message is already read — the exact shape glance
hits. Computed from a scoped MAX(message_id) query (tenant and
address), never from the page itself, so it cannot read stale or leak
across the tenant/address boundary (D-73).

total's meaning is unchanged: it already counted every filter match
independent of limit (the count query runs against the pre-limit
subquery), so since_message_id just becomes one more filter it counts
against.

Three mutations run and reverted to confirm the new tests actually
cover the behavior: message_id > vs >= broke the boundary test;
computing newest_message_id from the sorted page instead of the whole
mailbox broke the older-unread test; dropping the address filter from
the MAX query broke the address-boundary test.

583 -> 592 tests passing; test database left at head (010).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 00:55:31 +02:00
jpmschweitzerandClaude Opus 5.5 0055bb8c1c chore(plan): T-3, T-12 and T-14 done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:35:11 +02:00
jpmschweitzerandClaude Sonnet 5 2f53722078 fix(tests): a failing database test no longer prints the password (T-12)
pytest prints a failing test's fixture arguments via repr() as part of the
default traceback header — no -v or --showlocals needed — so any test
taking the test_database_url fixture and failing printed the real
TEST_DATABASE_URL straight into the terminal, password included.

test_database_url now returns a MaskedURL: a str subclass identical to a
plain string for every functional use (passed to .replace(), urlsplit(),
a subprocess environment) but whose repr() masks the password while still
showing host and database name. __str__ is untouched, so every caller
still connects with the real value.

tests/test_conftest.py proves it, both for the fixture itself and for
MaskedURL's string behaviour in isolation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 00:28:34 +02:00
jpmschweitzerandClaude Sonnet 5 a81b9a071b fix(tests): migration round-trip tests leave core_api_test at head (T-14)
Every round-trip test across test_book_migration.py, test_scribe_migration.py
and test_tenants_migration.py re-upgraded to its own revision on the way back
up rather than to head, so a full suite run left core_api_test one or two
migrations behind (008, with 009/010 released since). Each now ends on
`upgrade head`; a new TestSchemaEndsAtHeadAfterTheMigrationSuite class checks
the final state directly with no upgrade of its own, as a regression guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 00:27:54 +02:00
jpmschweitzerandClaude Sonnet 5 3a3dc959be fix(alembic): refuse to run without a named migration target (T-3)
`alembic/env.py` took its target from `get_settings().database_url`,
composed from `.env`'s production `POSTGRES_*` values, so the most natural
command in the repo — a bare `alembic current` — talked to production.
`-x target=test` and `-x target=production` now name the database
explicitly; anything else exits non-zero before any engine is built, and
the one line it prints before migrating never shows a credential.

`run_alembic` (tests/conftest.py) passes `-x target=test` and sets
`TEST_DATABASE_URL` in the subprocess environment instead of overriding
`POSTGRES_*` — the `_components()` shim is gone; nothing else used it.

`make migrate-test`, `make migrate-production` and `make dev-test-server`
are the three documented ways to reach either database now; `dev-test-server`
refuses to start if the resolved database is not `core_api_test`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 00:26:48 +02:00
jpmschweitzerandClaude Opus 5.5 237bdf9306 chore(plan): T-15 done in 1.15.0; T-3 and T-14 evidence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:40:25 +02:00
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
21 changed files with 1243 additions and 69 deletions
+112
View File
@@ -107,3 +107,115 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
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; 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 ('06GNBAZ6KW704AF07ZBR3YC868', 'status', 'backlog', 'done', NULL, '2026-10-19 20:16:05', '2026-10-19 20:16:05.567', '2026-10-19 20:16:05.567', NULL, 'd4fb4422de5f1d47f6ab52904623cd0d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'description', '`GET /scribe/mailbox` sorts unread first, then by id descending, behind one `before_id` cursor — two sort keys, one cursor. No cursor choice is safe in every case: paging below the last row is correct whenever a page holds a read entry, but a page of only unread entries (more than 200 unread) skips read entries with higher ids. Both clients that turn a message number into a mailbox entry — the Office (`src/systems/scribe.py` `_resolve_entry_id`) and `toj scribe mark` — page the mailbox to do it.
**Fix:** a `message_id` query parameter on `GET /scribe/mailbox` that answers the caller''s own entry for that message (zero or one), so neither client pages. Optionally, a cursor that matches the sort (`before` over `(read, id)`) for the listing itself.
Done when: both clients drop their paging for a single call, and a test seeds an old unread entry ahead of more than a page of read ones and finds a read entry beyond the first page.', '`GET /scribe/mailbox` sorts unread first, then by id descending, behind one `before_id` cursor — two sort keys, one cursor. No cursor choice is safe in every case: paging below the last row is correct whenever a page holds a read entry, but a page of only unread entries (more than 200 unread) skips read entries with higher ids. Both clients that turn a message number into a mailbox entry — the Office (`src/systems/scribe.py` `_resolve_entry_id`) and `toj scribe mark` — page the mailbox to do it.
**Fix:** a `message_id` query parameter on `GET /scribe/mailbox` that answers the caller''s own entry for that message (zero or one), so neither client pages. Optionally, a cursor that matches the sort (`before` over `(read, id)`) for the listing itself.
Done when: both clients drop their paging for a single call, and a test seeds an old unread entry ahead of more than a page of read ones and finds a read entry beyond the first page.
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.211', '2026-10-19 20:58:36.211', NULL, 'dd28daf8baefbf8c60d5842445ac6672', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'status', 'backlog', 'done', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.314', '2026-10-19 20:58:36.314', NULL, '83fae52bf6b40a1dcef4a89b9d8285d9', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'description', 'The user wants a way to get rid of a message that is no longer useful. A message is retained and shared by every recipient (workspace D-66 — history is how agents rebuild context), so deletion is **per mailbox**, in D-70''s vocabulary:
- **archive / unarchive** — the entry leaves the default mailbox listing; still in `history`, still findable.
- **trash / restore** — the entry leaves the mailbox, restorable; trashed entries are listed only when asked for.
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket.
**core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.', 'The user wants a way to get rid of a message that is no longer useful. A message is retained and shared by every recipient (workspace D-66 — history is how agents rebuild context), so deletion is **per mailbox**, in D-70''s vocabulary:
- **archive / unarchive** — the entry leaves the default mailbox listing; still in `history`, still findable.
- **trash / restore** — the entry leaves the mailbox, restorable; trashed entries are listed only when asked for.
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket.
**core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.431', '2026-10-19 20:58:36.431', NULL, 'be754e2e77e34e20c3b7bf511adefdce', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'status', 'backlog', 'done', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.539', '2026-10-19 20:58:36.539', NULL, '1673ea85b251b578be2248e3d3fe6f54', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'description', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
**Corrections, found reviewing T-1 (2026-10-19):**
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
**Corrections, found reviewing T-1 (2026-10-19):**
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.
**When the Book''s archive/trash verbs ship:** its `view=archive` filter (`src/domains/book/service.py`) matches `archived_at IS NOT NULL` alone, so a record both archived and trashed would show there. The Scribe''s (1.14.0) is archived and not trashed — trash wins. Make the Book agree. Found in round core-api-T-13-001.', NULL, '2026-10-19 20:58:36', '2026-10-19 20:58:36.679', '2026-10-19 20:58:36.679', NULL, 'ab2af45b461fbd0e84d422e625afaff2', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBZZR0RQKSAJ5FKF4X540KC', 'description', 'Adopted by the user (workspace D-70, the feedback paragraph; tatlock-office T-6): the Office''s failure channel must not depend on identity. A Scribe message may be stored with no sender address — from_identity null, from_consumer named — only when it is feedback to tower-control alone (tagged feedback, addressed to tower-control and nothing else). Every other send still requires the caller''s own address. Responses carry the null sender as such. The Office (tatlock-office T-6) sends it.', 'Adopted by the user (workspace D-70, the feedback paragraph; tatlock-office T-6): the Office''s failure channel must not depend on identity. A Scribe message may be stored with no sender address — from_identity null, from_consumer named — only when it is feedback to tower-control alone (tagged feedback, addressed to tower-control and nothing else). Every other send still requires the caller''s own address. Responses carry the null sender as such. The Office (tatlock-office T-6) sends it.
Live in core-api 1.15.0 (2026-10-19); production migrated to 010. Round core-api-T-15-001: 577 passed; three mutation checks each failed only their own cases.', NULL, '2026-10-19 21:40:25', '2026-10-19 21:40:25.352', '2026-10-19 21:40:25.352', NULL, 'edac5067cff35bc3d688472893b3a85a', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBZZR0RQKSAJ5FKF4X540KC', 'status', 'backlog', 'done', NULL, '2026-10-19 21:40:25', '2026-10-19 21:40:25.488', '2026-10-19 21:40:25.488', NULL, '133d4dbe55b577a764096f1f3b0756c0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'description', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.', NULL, '2026-10-19 21:40:25', '2026-10-19 21:40:25.647', '2026-10-19 21:40:25.647', NULL, '41fe70178658d2e1e299492755e0d295', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'description', 'Each migration file''s round-trip test re-upgrades to its own revision, not to head (tests/test_tenants_migration.py ends on 008), so since 009 a full suite run leaves core_api_test one behind head and the next manual check starts from a stale schema. End every round-trip test on head. Found in round core-api-T-13-001.', 'Each migration file''s round-trip test re-upgrades to its own revision, not to head (tests/test_tenants_migration.py ends on 008), so since 009 a full suite run leaves core_api_test one behind head and the next manual check starts from a stale schema. End every round-trip test on head. Found in round core-api-T-13-001.
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).', NULL, '2026-10-19 21:40:25', '2026-10-19 21:40:25.820', '2026-10-19 21:40:25.820', NULL, '62065aa1d8fd7ad21e9a7abd2f70f0c1', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNCC8BZT7RYQC16XK1MXBBRM', 'description', 'For the Office''s polling (tatlock-office T-11): GET /scribe/mailbox takes since_message_id=N — only the caller''s entries whose message number is above N, combined with view/unread/tag as today — and total counts what matched, so unread=true&limit=1 is a one-row unread count. Waits for round core-api-T-3-001, which is in this repo now.', 'For the Office''s polling (tatlock-office T-11): GET /scribe/mailbox takes since_message_id=N — only the caller''s entries whose message number is above N, combined with view/unread/tag as today — and total counts what matched, so unread=true&limit=1 is a one-row unread count. Waits for round core-api-T-3-001, which is in this repo now.
**Also: the newest message number.** The mailbox response carries the caller''s newest message number across its whole mailbox, whatever the filters (`newest_message_id`). The Office''s `glance` needs it: the unread-first sort makes `limit=1` return an older unread message when a newer one is already read (found in round tatlock-office-T-11-001).', NULL, '2026-10-19 22:25:51', '2026-10-19 22:25:51.654', '2026-10-19 22:25:51.654', NULL, '257d4c93e0c8ff4691ec032c7dd7f0f8', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'description', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', NULL, '2026-10-19 22:35:10', '2026-10-19 22:35:10.863', '2026-10-19 22:35:10.863', NULL, 'cd699cb99d78be586d2808f37152f056', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'status', 'backlog', 'done', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.000', '2026-10-19 22:35:11.000', NULL, 'd75c90d9b2454d4c5dcf0932aa36aea0', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'description', 'Each migration file''s round-trip test re-upgrades to its own revision, not to head (tests/test_tenants_migration.py ends on 008), so since 009 a full suite run leaves core_api_test one behind head and the next manual check starts from a stale schema. End every round-trip test on head. Found in round core-api-T-13-001.
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).', 'Each migration file''s round-trip test re-upgrades to its own revision, not to head (tests/test_tenants_migration.py ends on 008), so since 009 a full suite run leaves core_api_test one behind head and the next manual check starts from a stale schema. End every round-trip test on head. Found in round core-api-T-13-001.
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.145', '2026-10-19 22:35:11.145', NULL, '6dd54bb01ed3a435150837ad5d1fa1be', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'status', 'backlog', 'done', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.296', '2026-10-19 22:35:11.296', NULL, '779820a64d072b6c946f9b07e079bb00', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBHXYDJEEDQEADCP3JTR7BR', 'description', 'Found in round core-api-T-11-001: when a test that takes the `test_database_url` fixture fails under `pytest -v`, pytest shows the fixture''s value in the traceback — the `core_api_test` URL with its password. Local test credential, not production''s, but Rule 3 draws no line on severity, and every debugging session that lets one fail under -v repeats it. Fix the fixture so its value never renders (a repr-safe wrapper, or pass the parts rather than the URL), and say in CLAUDE.md how to debug a DB test without it.', 'Found in round core-api-T-11-001: when a test that takes the `test_database_url` fixture fails under `pytest -v`, pytest shows the fixture''s value in the traceback — the `core_api_test` URL with its password. Local test credential, not production''s, but Rule 3 draws no line on severity, and every debugging session that lets one fail under -v repeats it. Fix the fixture so its value never renders (a repr-safe wrapper, or pass the parts rather than the URL), and say in CLAUDE.md how to debug a DB test without it.
Done in round core-api-T-3-001 (2f53722): the fixture''s repr masks the password. **Residual, out of scope:** asyncpg''s own InvalidPasswordError traceback prints ConnectionParameters with the password from inside the library — a separate, third-party surface.', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.418', '2026-10-19 22:35:11.418', NULL, '93ca44bcf4fa3cb2612df713fbaf7162', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBHXYDJEEDQEADCP3JTR7BR', 'status', 'backlog', 'done', NULL, '2026-10-19 22:35:11', '2026-10-19 22:35:11.544', '2026-10-19 22:35:11.544', NULL, 'f03cf5fb39db181595aa47948e980228', 2) ON CONFLICT(hash) DO NOTHING;
+3
View File
@@ -11,3 +11,6 @@ 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 ('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 ('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 ('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 ('06GNBR1VBM8SZP8DDK93SFS608', 'T-13', '2026-10-19 20:35:21.053', '2026-10-19 20:35:21.053', NULL, 'aab7f828f5f01bd8310c85f53ff0dc19', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'T-14', '2026-10-19 20:58:36.809', '2026-10-19 20:58:36.809', NULL, '65086d233f5bc652cec0aa79dcaec2c4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBZZR0RQKSAJ5FKF4X540KC', 'T-15', '2026-10-19 21:10:00.967', '2026-10-19 21:10:00.967', NULL, '799679cb2e45f2a0c5ea07da08aa93e1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNCC8BZT7RYQC16XK1MXBBRM', 'T-16', '2026-10-19 22:03:37.343', '2026-10-19 22:03:37.343', NULL, '092ac223950852535456b31c45a8ae04', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
+109
View File
@@ -150,3 +150,112 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket. - 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; **core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 20:35:21.053', '2026-10-19 20:35:21.053', NULL, 'd4fe2d5d06329b627a04dec089ca8a8b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'task', NULL, 'The mailbox answers by message number, so no client pages to find an entry', '`GET /scribe/mailbox` sorts unread first, then by id descending, behind one `before_id` cursor — two sort keys, one cursor. No cursor choice is safe in every case: paging below the last row is correct whenever a page holds a read entry, but a page of only unread entries (more than 200 unread) skips read entries with higher ids. Both clients that turn a message number into a mailbox entry — the Office (`src/systems/scribe.py` `_resolve_entry_id`) and `toj scribe mark` — page the mailbox to do it.
**Fix:** a `message_id` query parameter on `GET /scribe/mailbox` that answers the caller''s own entry for that message (zero or one), so neither client pages. Optionally, a cursor that matches the sort (`before` over `(read, id)`) for the listing itself.
Done when: both clients drop their paging for a single call, and a test seeds an old unread entry ahead of more than a page of read ones and finds a read entry beyond the first page.
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 17:07:53.702', '2026-10-19 20:58:36.211', NULL, '464c90f9c8ff84163446b97f2e5fd2ad', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNA8JCWVP31DKSE7F4EMF6G4', 'task', NULL, 'The mailbox answers by message number, so no client pages to find an entry', '`GET /scribe/mailbox` sorts unread first, then by id descending, behind one `before_id` cursor — two sort keys, one cursor. No cursor choice is safe in every case: paging below the last row is correct whenever a page holds a read entry, but a page of only unread entries (more than 200 unread) skips read entries with higher ids. Both clients that turn a message number into a mailbox entry — the Office (`src/systems/scribe.py` `_resolve_entry_id`) and `toj scribe mark` — page the mailbox to do it.
**Fix:** a `message_id` query parameter on `GET /scribe/mailbox` that answers the caller''s own entry for that message (zero or one), so neither client pages. Optionally, a cursor that matches the sort (`before` over `(read, id)`) for the listing itself.
Done when: both clients drop their paging for a single call, and a test seeds an old unread entry ahead of more than a page of read ones and finds a read entry beyond the first page.
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', 'done', 'medium', NULL, NULL, NULL, '2026-10-19 17:07:53.702', '2026-10-19 20:58:36.314', NULL, 'd49072b54cd82406d0e4cc7e999ff331', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'story', NULL, 'Archive and trash a message in your own mailbox', 'The user wants a way to get rid of a message that is no longer useful. A message is retained and shared by every recipient (workspace D-66 — history is how agents rebuild context), so deletion is **per mailbox**, in D-70''s vocabulary:
- **archive / unarchive** — the entry leaves the default mailbox listing; still in `history`, still findable.
- **trash / restore** — the entry leaves the mailbox, restorable; trashed entries are listed only when asked for.
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket.
**core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 20:35:21.053', '2026-10-19 20:58:36.431', NULL, 'c21eed83c0a16a0affce7a5d53935efc', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBR1VBM8SZP8DDK93SFS608', 'story', NULL, 'Archive and trash a message in your own mailbox', 'The user wants a way to get rid of a message that is no longer useful. A message is retained and shared by every recipient (workspace D-66 — history is how agents rebuild context), so deletion is **per mailbox**, in D-70''s vocabulary:
- **archive / unarchive** — the entry leaves the default mailbox listing; still in `history`, still findable.
- **trash / restore** — the entry leaves the mailbox, restorable; trashed entries are listed only when asked for.
- True erasure of a message''s content stays `redact` (write, audited) — not this ticket.
**core-api:** `scribe_mailbox_entries` gains `archived_at` and `trashed_at`; `PATCH /scribe/mailbox/{entry_id}` takes `archived` and `trashed` booleans beside `read`; `GET /scribe/mailbox` takes `view` (`inbox` default: neither archived nor trashed · `archive` · `trash` · `all`). Paired with T-9 (look an entry up by message number) in one round, since both touch the same route.
Live in core-api 1.14.0 (2026-10-19); production migrated to 009. Round core-api-T-13-001: 566 passed; three mutation checks each failed their test.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 20:35:21.053', '2026-10-19 20:58:36.539', NULL, 'd12ebcdf5e52795a05a51c0620565172', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
**Corrections, found reviewing T-1 (2026-10-19):**
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.
**When the Book''s archive/trash verbs ship:** its `view=archive` filter (`src/domains/book/service.py`) matches `archived_at IS NOT NULL` alone, so a record both archived and trashed would show there. The Scribe''s (1.14.0) is archived and not trashed — trash wins. Make the Book agree. Found in round core-api-T-13-001.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 20:58:36.678', NULL, 'fb33d075391ff2771a3361218f0035b7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'task', NULL, 'Migration round-trip tests leave core_api_test behind head', 'Each migration file''s round-trip test re-upgrades to its own revision, not to head (tests/test_tenants_migration.py ends on 008), so since 009 a full suite run leaves core_api_test one behind head and the next manual check starts from a stale schema. End every round-trip test on head. Found in round core-api-T-13-001.', 'backlog', 'low', NULL, NULL, NULL, '2026-10-19 20:58:36.809', '2026-10-19 20:58:36.809', NULL, '52f32db6c5ddcdd77f3eb995a64e8ba5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBZZR0RQKSAJ5FKF4X540KC', 'story', NULL, 'A feedback message to tower-control may have no sender', 'Adopted by the user (workspace D-70, the feedback paragraph; tatlock-office T-6): the Office''s failure channel must not depend on identity. A Scribe message may be stored with no sender address — from_identity null, from_consumer named — only when it is feedback to tower-control alone (tagged feedback, addressed to tower-control and nothing else). Every other send still requires the caller''s own address. Responses carry the null sender as such. The Office (tatlock-office T-6) sends it.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 21:10:00.967', '2026-10-19 21:10:00.967', NULL, '46c72fcabe09acd063b713da5aef66f2', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBZZR0RQKSAJ5FKF4X540KC', 'story', NULL, 'A feedback message to tower-control may have no sender', 'Adopted by the user (workspace D-70, the feedback paragraph; tatlock-office T-6): the Office''s failure channel must not depend on identity. A Scribe message may be stored with no sender address — from_identity null, from_consumer named — only when it is feedback to tower-control alone (tagged feedback, addressed to tower-control and nothing else). Every other send still requires the caller''s own address. Responses carry the null sender as such. The Office (tatlock-office T-6) sends it.
Live in core-api 1.15.0 (2026-10-19); production migrated to 010. Round core-api-T-15-001: 577 passed; three mutation checks each failed only their own cases.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 21:10:00.967', '2026-10-19 21:40:25.352', NULL, '55c6b195649162421fc352c2b50a9fe9', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBZZR0RQKSAJ5FKF4X540KC', 'story', NULL, 'A feedback message to tower-control may have no sender', 'Adopted by the user (workspace D-70, the feedback paragraph; tatlock-office T-6): the Office''s failure channel must not depend on identity. A Scribe message may be stored with no sender address — from_identity null, from_consumer named — only when it is feedback to tower-control alone (tagged feedback, addressed to tower-control and nothing else). Every other send still requires the caller''s own address. Responses carry the null sender as such. The Office (tatlock-office T-6) sends it.
Live in core-api 1.15.0 (2026-10-19); production migrated to 010. Round core-api-T-15-001: 577 passed; three mutation checks each failed only their own cases.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 21:10:00.967', '2026-10-19 21:40:25.487', NULL, 'f341424841045769b2232e09f3c06326', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 21:40:25.647', NULL, 'e5e46c76d533149faf0b37fe13044006', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'task', NULL, 'Migration round-trip tests leave core_api_test behind head', 'Each migration file''s round-trip test re-upgrades to its own revision, not to head (tests/test_tenants_migration.py ends on 008), so since 009 a full suite run leaves core_api_test one behind head and the next manual check starts from a stale schema. End every round-trip test on head. Found in round core-api-T-13-001.
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).', 'backlog', 'low', NULL, NULL, NULL, '2026-10-19 20:58:36.809', '2026-10-19 21:40:25.820', NULL, '4501f0022158bcc0083cd2309d79c115', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNCC8BZT7RYQC16XK1MXBBRM', 'task', NULL, 'The mailbox answers only messages after N, with a count', 'For the Office''s polling (tatlock-office T-11): GET /scribe/mailbox takes since_message_id=N — only the caller''s entries whose message number is above N, combined with view/unread/tag as today — and total counts what matched, so unread=true&limit=1 is a one-row unread count. Waits for round core-api-T-3-001, which is in this repo now.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 22:03:37.343', '2026-10-19 22:03:37.343', NULL, 'fd4a1b4c6ed521ffaaa246665f7e45ca', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNCC8BZT7RYQC16XK1MXBBRM', 'task', NULL, 'The mailbox answers only messages after N, with a count', 'For the Office''s polling (tatlock-office T-11): GET /scribe/mailbox takes since_message_id=N — only the caller''s entries whose message number is above N, combined with view/unread/tag as today — and total counts what matched, so unread=true&limit=1 is a one-row unread count. Waits for round core-api-T-3-001, which is in this repo now.
**Also: the newest message number.** The mailbox response carries the caller''s newest message number across its whole mailbox, whatever the filters (`newest_message_id`). The Office''s `glance` needs it: the unread-first sort makes `limit=1` return an older unread message when a newer one is already read (found in round tatlock-office-T-11-001).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 22:03:37.343', '2026-10-19 22:25:51.653', NULL, '8f6daa3a748e4b26d6c7ce40fc9ee8a0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 22:35:10.863', NULL, '1afa273c4e58785108159efc17bf7aa7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.
**Bitten twice now.** Round core-api-T-15-001''s first command was a bare `alembic current` — production, read-only, answered 009, nothing changed — despite the work order''s warning; earlier rounds derived the test target by hand every time. Alembic should refuse to run without a named target, or default to the test database.
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', 'done', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 22:35:10.999', NULL, '6a9a2751c2c65907dac8dd105fec266b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'task', NULL, 'Migration round-trip tests leave core_api_test behind head', 'Each migration file''s round-trip test re-upgrades to its own revision, not to head (tests/test_tenants_migration.py ends on 008), so since 009 a full suite run leaves core_api_test one behind head and the next manual check starts from a stale schema. End every round-trip test on head. Found in round core-api-T-13-001.
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', 'backlog', 'low', NULL, NULL, NULL, '2026-10-19 20:58:36.809', '2026-10-19 22:35:11.145', NULL, '0badbf207b4a0001fa2d45906cd8bc6f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBXC7H3JBESKFS20BV71BJG', 'task', NULL, 'Migration round-trip tests leave core_api_test behind head', 'Each migration file''s round-trip test re-upgrades to its own revision, not to head (tests/test_tenants_migration.py ends on 008), so since 009 a full suite run leaves core_api_test one behind head and the next manual check starts from a stale schema. End every round-trip test on head. Found in round core-api-T-13-001.
Grows by one with every migration: after 010, a full suite run leaves core_api_test at 008, two behind head (round core-api-T-15-001).
Done in round core-api-T-3-001 (commits 3a3dc95, a81b9a0, 2f53722; 583 passed; mutation checks each failed their test). Ships with the next core-api release.', 'done', 'low', NULL, NULL, NULL, '2026-10-19 20:58:36.809', '2026-10-19 22:35:11.296', NULL, '88884cb0b3db1fd86f7ef51ff23169b0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBHXYDJEEDQEADCP3JTR7BR', 'task', NULL, 'A failing database test under pytest -v prints the test database password', 'Found in round core-api-T-11-001: when a test that takes the `test_database_url` fixture fails under `pytest -v`, pytest shows the fixture''s value in the traceback — the `core_api_test` URL with its password. Local test credential, not production''s, but Rule 3 draws no line on severity, and every debugging session that lets one fail under -v repeats it. Fix the fixture so its value never renders (a repr-safe wrapper, or pass the parts rather than the URL), and say in CLAUDE.md how to debug a DB test without it.
Done in round core-api-T-3-001 (2f53722): the fixture''s repr masks the password. **Residual, out of scope:** asyncpg''s own InvalidPasswordError traceback prints ConnectionParameters with the password from inside the library — a separate, third-party surface.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 20:08:36.204', '2026-10-19 22:35:11.418', NULL, '42290765032587681dfcbf22269c6cd8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNBHXYDJEEDQEADCP3JTR7BR', 'task', NULL, 'A failing database test under pytest -v prints the test database password', 'Found in round core-api-T-11-001: when a test that takes the `test_database_url` fixture fails under `pytest -v`, pytest shows the fixture''s value in the traceback — the `core_api_test` URL with its password. Local test credential, not production''s, but Rule 3 draws no line on severity, and every debugging session that lets one fail under -v repeats it. Fix the fixture so its value never renders (a repr-safe wrapper, or pass the parts rather than the URL), and say in CLAUDE.md how to debug a DB test without it.
Done in round core-api-T-3-001 (2f53722): the fixture''s repr masks the password. **Residual, out of scope:** asyncpg''s own InvalidPasswordError traceback prints ConnectionParameters with the password from inside the library — a separate, third-party surface.', 'done', 'medium', NULL, NULL, NULL, '2026-10-19 20:08:36.204', '2026-10-19 22:35:11.544', NULL, '43971c390f52501fa6345b15d96ccba0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
+51
View File
@@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.16.0] - 2026-10-20
### Added
- **`GET /scribe/mailbox` takes `since_message_id`, and answers with `newest_message_id`**
(core-api T-16) — the Office's `glance` call needs "has anything changed since I last
looked?" with counts. `since_message_id=N` keeps only entries whose message number is
above `N`, combined with `view`/`unread`/`tag` as today (`message_id`, which already
bypasses the other filters, keeps bypassing `since_message_id` too). `total` already
counted every match independent of `limit`; that is unchanged, `since_message_id` is
just one more filter it counts against. `newest_message_id` is the caller's newest
message number across its whole mailbox — every view, regardless of any filter applied
— null when the mailbox is empty; it exists because the unread-first sort can return an
older unread entry on a `limit=1` page while a newer message is already read.
### Changed
- **Alembic refuses to run without a named migration target** (core-api T-3) —
`alembic/env.py` exits non-zero before building any connection unless `-x target=test`
or `-x target=production` is given; nothing defaults, so the most natural command in
the repo, a bare `alembic current`, can no longer reach production by accident.
`make migrate-test`, `make migrate-production` and `make dev-test-server` are the three
documented ways to reach either database; none of them print a password or a
credential-bearing URL.
### Fixed
- **Migration round-trip tests leave `core_api_test` at head** (core-api T-14) — every
round-trip test in `tests/test_book_migration.py`, `tests/test_scribe_migration.py` and
`tests/test_tenants_migration.py` now ends on `upgrade head` rather than its own
revision, so a full suite run leaves the test database at head instead of one or two
migrations behind it.
- **A failing database test no longer prints the test database password** (core-api
T-12) — the `test_database_url` fixture now returns a `MaskedURL`: identical to a
plain string for every functional use, but its `repr()` — which pytest prints for a
failing test's fixture arguments, without needing `-v` — masks the password.
## [1.15.0] - 2026-10-19
### Added
- **A `feedback` message to `tower-control` may have no sender** (`scribe_messages.from_identity`
nullable, migration 010, core-api T-15 / workspace D-70) — the failure channel must not depend
on the thing most likely to break. `POST /scribe/messages` accepts a missing
`X-Office-Identity` for exactly one shape: an office caller, `to` is `["tower-control"]` alone,
and `tags` contains `feedback`; stored with `from_identity` null and `from_consumer` the
caller's consumer. Every other send without an identity is still 400 `identity_required`, and
a `user`-kind caller gains nothing from the exemption. `MessageResponse.from_identity` is now
optional, so `mailbox`, `history`, `find` and the `message_id` lookup all answer such a message
with a null sender.
## [1.14.0] - 2026-10-19 ## [1.14.0] - 2026-10-19
### Added ### Added
+23
View File
@@ -83,6 +83,24 @@ live under `src/domains/<name>/models.py` and must be imported in `alembic/env.p
register with `Base.metadata` — check that file when adding a new model or `alembic register with `Base.metadata` — check that file when adding a new model or `alembic
revision --autogenerate` will silently miss it. revision --autogenerate` will silently miss it.
**A bare `alembic` refuses — it names no target (core-api T-3).** `alembic/env.py`
exits non-zero before building any connection unless told explicitly which database:
`-x target=test` (the database `TEST_DATABASE_URL` names) or `-x target=production`
(today's settings-derived database — real production). Nothing defaults, because a bare
`.venv/bin/alembic current` once reached production by accident, read-only but still
unintended. Three `make` targets are the only documented way to reach either database:
```bash
make migrate-test # upgrade core_api_test to head
make migrate-production # the one documented way to migrate production — says what
# it's about to do, then alembic -x target=production upgrade head
make dev-test-server # uvicorn on :8777 against core_api_test; refuses to start
# if TEST_DATABASE_URL resolves to any other database name
```
None of the three ever print a password or a credential-bearing URL — only the
database name, and (for `migrate-production`) an announcement before it runs.
## Working here ## Working here
**Plan, act, reflect.** Outline which files you will touch and the side effects before **Plan, act, reflect.** Outline which files you will touch and the side effects before
@@ -93,6 +111,11 @@ does the new behaviour have a test?
code changes (not on `requirements.txt` changes; restart the container/script after code changes (not on `requirements.txt` changes; restart the container/script after
adding a dependency). Deploy only when a feature is complete and tested. adding a dependency). Deploy only when a feature is complete and tested.
**The full suite takes about 200 seconds** — longer than a two-minute command timeout, and a
run killed mid-suite can leave `core_api_test` between migrations. Give `make test` at least
five minutes, and confirm the test database is at head (`.venv/bin/alembic -x target=test
current`) after an interrupted run.
Run tests through the venv explicitly, to avoid environment mismatch: Run tests through the venv explicitly, to avoid environment mismatch:
```bash ```bash
+29
View File
@@ -38,6 +38,35 @@ test: ## Run the test suite
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; } @test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
$(VENV)/bin/python -m pytest tests/ $(VENV)/bin/python -m pytest tests/
# Alembic refuses to run with no named target, or any other one (core-api
# T-3) — `alembic/env.py` exits before connecting rather than falling back
# to production. These three targets are the only documented way to reach
# either database.
.PHONY: migrate-test
migrate-test: ## Upgrade core_api_test (TEST_DATABASE_URL) to head
@test -x $(VENV)/bin/alembic || { echo "FAIL — no venv; run: make setup"; exit 69; }
$(VENV)/bin/alembic -x target=test upgrade head
.PHONY: migrate-production
migrate-production: ## The one documented way to migrate production: alembic -x target=production upgrade head
@test -x $(VENV)/bin/alembic || { echo "FAIL — no venv; run: make setup"; exit 69; }
@echo "migrate-production: about to run 'alembic -x target=production upgrade head' against the production database"
$(VENV)/bin/alembic -x target=production upgrade head
.PHONY: dev-test-server
dev-test-server: ## uvicorn on :8777 against core_api_test only (refuses on any other resolved database; never prints credentials)
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
@url="$$TEST_DATABASE_URL"; \
if [ -z "$$url" ]; then url=$$(grep -m1 '^TEST_DATABASE_URL=' .env 2>/dev/null | cut -d= -f2-); fi; \
if [ -z "$$url" ]; then echo "FAIL — TEST_DATABASE_URL is not set (environment or .env)"; exit 69; fi; \
rest=$${url#postgresql://}; creds=$${rest%%@*}; hostdb=$${rest#*@}; \
puser=$${creds%%:*}; ppass=$${creds#*:}; phost=$${hostdb%%/*}; pdb=$${hostdb#*/}; \
if [ "$$pdb" != "core_api_test" ]; then echo "FAIL — resolved database is '$$pdb', not core_api_test; refusing to start"; exit 1; fi; \
echo "dev-test-server: database=$$pdb"; \
POSTGRES_HOST="$$phost" POSTGRES_USER="$$puser" POSTGRES_PASSWORD="$$ppass" POSTGRES_DATABASE="$$pdb" \
$(VENV)/bin/python -m uvicorn src.main:app --reload --host 0.0.0.0 --port 8777
# No `lint` target, deliberately. This repo configures no linter — no ruff or # No `lint` target, deliberately. This repo configures no linter — no ruff or
# flake8 config, and neither in requirements. Per D-27 the name is reserved for # flake8 config, and neither in requirements. Per D-27 the name is reserved for
# repos that lint; an empty target here would report clean for something never # repos that lint; an empty target here would report clean for something never
+75 -8
View File
@@ -2,18 +2,30 @@
Alembic Environment Configuration Alembic Environment Configuration
Async migration environment for SQLAlchemy 2.0 with asyncpg. Async migration environment for SQLAlchemy 2.0 with asyncpg.
Alembic refuses to run without an explicit target (core-api T-3): a bare
`alembic current` used to fall back to `get_settings().database_url`, which
is composed from `.env`'s production `POSTGRES_*` values — the most natural
command in the repo was quietly talking to production. `-x target=test`
names the database `TEST_DATABASE_URL` points at; `-x target=production`
names today's settings-derived database, deliberately. Nothing else
defaults, and the check runs — and can exit — before any engine is built.
""" """
import asyncio import asyncio
import os
import re
import sys
from logging.config import fileConfig from logging.config import fileConfig
from pathlib import Path
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection, make_url
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context from alembic import context
# Import our models and config # Import our models (config is imported lazily, inside _production_url, so
from src.shared.config import get_settings # that resolving a bad or missing target never has to construct Settings).
from src.shared.database import Base from src.shared.database import Base
# Import all models to ensure they're registered with Base.metadata # Import all models to ensure they're registered with Base.metadata
@@ -27,6 +39,8 @@ from src.domains.scribe.models import ( # noqa: F401
) )
from src.domains.tenants.models import Tenant, TenantConsumer # noqa: F401 from src.domains.tenants.models import Tenant, TenantConsumer # noqa: F401
REPO_ROOT = Path(__file__).resolve().parent.parent
# Alembic Config object # Alembic Config object
config = context.config config = context.config
@@ -37,11 +51,64 @@ if config.config_file_name is not None:
# Model metadata for autogenerate support # Model metadata for autogenerate support
target_metadata = Base.metadata target_metadata = Base.metadata
# Get database URL from our settings
settings = get_settings() def _test_url() -> str:
db_url = settings.database_url """TEST_DATABASE_URL from the environment, else the repo's own `.env` —
if db_url.startswith("postgresql://"): read that one variable only; the file is never loaded or sourced whole."""
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1) value = os.environ.get("TEST_DATABASE_URL")
if value:
return value
env_path = REPO_ROOT / ".env"
if env_path.is_file():
with open(env_path) as f:
for line in f:
match = re.match(r"^TEST_DATABASE_URL=(.*)$", line.rstrip("\n"))
if match:
return match.group(1).strip().strip("'\"")
raise SystemExit(
"alembic: -x target=test needs TEST_DATABASE_URL (environment, or "
"the repo's .env) — none found."
)
def _production_url() -> str:
"""Today's settings-derived database — deliberately, only when asked
for by name."""
from src.shared.config import get_settings
return get_settings().database_url
def _resolve_target_url() -> str:
"""-x target=test|production names the database to migrate. No target,
or anything else, exits non-zero here — before any engine is built."""
x_args = context.get_x_argument(as_dictionary=True)
target = x_args.get("target")
if target == "test":
url = _test_url()
elif target == "production":
url = _production_url()
else:
raise SystemExit(
"alembic: no migration target named. Pass one of:\n"
" -x target=test (the database TEST_DATABASE_URL names)\n"
" -x target=production (today's settings-derived database)\n"
f"Got -x target={target!r}. Nothing defaults."
)
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
return url
db_url = _resolve_target_url()
# Name the target before migrating anything — never a host's credentials,
# never the URL itself.
print(f"alembic target: database={make_url(db_url).database!r}", file=sys.stderr)
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
@@ -0,0 +1,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] [project]
name = "core-api" name = "core-api"
version = "1.14.0" version = "1.16.0"
description = "Core Code API - Infrastructure management and tools API" description = "Core Code API - Infrastructure management and tools API"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+22 -4
View File
@@ -6,7 +6,11 @@ The Scribe's messaging verbs (dossier core-api T-5): register, list
a mailbox entry and look one up by message number (core-api T-13/T-9). 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 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 filters on the caller's tenant (D-73) — an address or message in another
tenant answers exactly like one that does not exist. tenant answers exactly like one that does not exist. `send` is the one
write that does not require an identity at the dependency layer: core-api
T-15 / D-70 lets an office caller's `feedback` to `tower-control` alone go
with no sender, so ScribeService.send_message enforces identity_required
itself, after seeing the body.
""" """
from typing import Optional from typing import Optional
@@ -35,6 +39,13 @@ logger = get_logger(__name__)
require_scribe_reader = require_caller("office", "user") require_scribe_reader = require_caller("office", "user")
require_scribe_writer = require_caller("office", "user", identity_required=True) require_scribe_writer = require_caller("office", "user", identity_required=True)
# `/messages` POST is not `identity_required` at this layer: core-api T-15 /
# workspace D-70 lets one shape through with no identity (an office
# caller's `feedback` to `tower-control` alone), and that decision needs
# the request body, which a caller dependency never sees. ScribeService
# .send_message is the single place that enforces identity_required for
# every other shape — see its docstring.
require_scribe_sender = require_caller("office", "user")
def _address_response(address, owned: bool) -> AddressResponse: def _address_response(address, owned: bool) -> AddressResponse:
@@ -127,7 +138,7 @@ class ScribeController(BaseController):
) )
async def send_message( async def send_message(
data: MessageSendRequest, data: MessageSendRequest,
caller: Caller = Depends(require_scribe_writer), caller: Caller = Depends(require_scribe_sender),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
try: try:
@@ -185,13 +196,18 @@ class ScribeController(BaseController):
message_id: Optional[int] = Query( message_id: Optional[int] = Query(
None, None,
description="look up the caller's own entry for this message number directly, " 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)", "in any view, ignoring unread/tag/before_id/since_message_id (core-api T-9)",
),
since_message_id: Optional[int] = Query(
None,
description="only entries whose message number is above this one, combined "
"with view/unread/tag as usual (ignored when message_id is given) (core-api T-16)",
), ),
caller: Caller = Depends(require_scribe_reader), caller: Caller = Depends(require_scribe_reader),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
try: try:
results, total = await service.get_mailbox( results, total, newest_message_id = await service.get_mailbox(
session, session,
caller, caller,
view=view, view=view,
@@ -200,6 +216,7 @@ class ScribeController(BaseController):
limit=limit, limit=limit,
before_id=before_id, before_id=before_id,
message_id=message_id, message_id=message_id,
since_message_id=since_message_id,
) )
except ScribeError as error: except ScribeError as error:
raise HTTPException( raise HTTPException(
@@ -209,6 +226,7 @@ class ScribeController(BaseController):
entries=[_entry_response(entry, message) for entry, message in results], entries=[_entry_response(entry, message) for entry, message in results],
total=total, total=total,
limit=limit, limit=limit,
newest_message_id=newest_message_id,
) )
@router.patch( @router.patch(
+6 -1
View File
@@ -90,7 +90,12 @@ class ScribeMessage(Base):
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False, index=True String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False, index=True
) )
topics: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list) topics: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
from_identity: Mapped[str] = mapped_column(String(63), nullable=False) # Nullable since migration 010 (core-api T-15 / D-70): a `feedback`
# message to `tower-control` alone may ship with no sender — the one
# shape the failure channel must not depend on an address for. The
# service layer (ScribeService._is_exempt_feedback) is what actually
# narrows which sends may leave this null.
from_identity: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
from_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True) from_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
body: Mapped[str] = mapped_column(Text, nullable=False) body: Mapped[str] = mapped_column(Text, nullable=False)
+12 -1
View File
@@ -49,7 +49,10 @@ class MessageSendRequest(BaseSchema):
class MessageResponse(BaseSchema): class MessageResponse(BaseSchema):
id: int id: int
topics: List[str] topics: List[str]
from_identity: str # Optional since core-api T-15 / D-70: a sender-less `feedback` message
# to `tower-control` answers with from_identity null, everywhere this
# schema is used (mailbox, history, find, message_id lookup).
from_identity: Optional[str]
from_consumer: Optional[str] from_consumer: Optional[str]
subject: Optional[str] subject: Optional[str]
body: str body: str
@@ -75,6 +78,14 @@ class MailboxListResponse(BaseSchema):
entries: List[MailboxEntryResponse] entries: List[MailboxEntryResponse]
total: int total: int
limit: int limit: int
# Only `GET /scribe/mailbox` sets this (core-api T-16); `GET /scribe/messages`
# (find) shares this schema and leaves it at the default.
newest_message_id: Optional[int] = Field(
None,
description="the caller's newest message number across the whole mailbox "
"(every view), regardless of the filters this call applied; null when the "
"mailbox is empty",
)
class HistoryResponse(BaseSchema): class HistoryResponse(BaseSchema):
+85 -17
View File
@@ -62,6 +62,19 @@ class ScribeService:
except TenantError as error: except TenantError as error:
raise ScribeError(error.code, error.status_code) from error raise ScribeError(error.code, error.status_code) from error
def _is_exempt_feedback(self, caller: Caller, recipients: List[str], tags: List[str]) -> bool:
"""core-api T-15 / workspace D-70 ("`feedback` needs no address"):
the one shape that may ship with no sender is an office caller's
`feedback` to `tower-control` alone — nothing broader. `recipients`
and `tags` are the de-duplicated lists `send_message` already
computed, so a second or different recipient, or a message missing
the `feedback` tag, never qualifies."""
return (
caller.kind == "office"
and recipients == ["tower-control"]
and "feedback" in tags
)
def _validate_address_name(self, name: str) -> None: def _validate_address_name(self, name: str) -> None:
"""Beyond the schema's regex (shape only): not `group-*`, not reserved. """Beyond the schema's regex (shape only): not `group-*`, not reserved.
Mirrors src/domains/auth/callers.py's `_validate_identity` rule — Mirrors src/domains/auth/callers.py's `_validate_identity` rule —
@@ -90,6 +103,22 @@ class ScribeService:
messages = (await session.execute(stmt)).scalars().all() messages = (await session.execute(stmt)).scalars().all()
return {message.id: message for message in messages} return {message.id: message for message in messages}
async def _newest_message_id(
self, session: AsyncSession, tenant: str, identity: str
) -> Optional[int]:
"""The caller's newest message number across its whole mailbox —
every view, independent of whatever filters the current call
applies (core-api T-16) — scoped to tenant *and* address so it can
never answer with another identity's or tenant's number (D-73).
`func.max` over no rows is SQL NULL, which the driver returns as
`None`: the empty-mailbox case falls out of the query rather than
needing a separate branch."""
stmt = select(func.max(ScribeMailboxEntry.message_id)).where(
ScribeMailboxEntry.owner_tenant == tenant,
ScribeMailboxEntry.address == identity,
)
return (await session.execute(stmt)).scalar()
# ========================================================================= # =========================================================================
# register / list addresses # register / list addresses
# ========================================================================= # =========================================================================
@@ -142,14 +171,38 @@ class ScribeService:
async def send_message( async def send_message(
self, session: AsyncSession, data: MessageSendRequest, caller: Caller self, session: AsyncSession, data: MessageSendRequest, caller: Caller
) -> ScribeMessage: ) -> ScribeMessage:
"""`send` — `from` is the caller's own identity, never the body.""" """`send` — `from` is the caller's own identity, never the body.
core-api T-15 / workspace D-70: `require_scribe_sender` (the
controller's dependency for this route) no longer enforces
`identity_required` itself, because that decision depends on the
body (`to`, `tags`), which a caller dependency cannot see. This
method is therefore the single place that enforces it: a missing
identity (`caller.identity == ""`, which only an office caller with
no `X-Office-Identity` header can produce) is allowed through
exactly for `_is_exempt_feedback`'s shape, and is still
`identity_required` for every other send — the tenant check stays
on this path too (no sender is not no tenant)."""
tenant = await self._require_tenant(session, caller) tenant = await self._require_tenant(session, caller)
from_address = await self._get_address(session, tenant, caller.identity)
if from_address is None or from_address.owner_consumer != caller.consumer:
raise ScribeError("identity_not_owned", 403)
recipients = list(dict.fromkeys(data.to)) # de-dup, keep order recipients = list(dict.fromkeys(data.to)) # de-dup, keep order
tags = list(dict.fromkeys(data.tags))
no_identity = caller.identity == ""
exempt = no_identity and self._is_exempt_feedback(caller, recipients, tags)
if no_identity and not exempt:
raise ScribeError("identity_required", 400)
if exempt:
from_identity = None
from_consumer = caller.consumer
else:
from_address = await self._get_address(session, tenant, caller.identity)
if from_address is None or from_address.owner_consumer != caller.consumer:
raise ScribeError("identity_not_owned", 403)
from_identity = caller.identity
from_consumer = caller.consumer
found_stmt = select(ScribeAddress.name).where( found_stmt = select(ScribeAddress.name).where(
ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients) ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients)
) )
@@ -163,13 +216,11 @@ class ScribeService:
if reply_target is None or reply_target.owner_tenant != tenant: if reply_target is None or reply_target.owner_tenant != tenant:
raise ScribeError("not_found", 404) raise ScribeError("not_found", 404)
tags = list(dict.fromkeys(data.tags))
message = ScribeMessage( message = ScribeMessage(
owner_tenant=tenant, owner_tenant=tenant,
topics=recipients, topics=recipients,
from_identity=caller.identity, from_identity=from_identity,
from_consumer=caller.consumer, from_consumer=from_consumer,
subject=data.subject, subject=data.subject,
body=data.body, body=data.body,
tags=tags, tags=tags,
@@ -187,7 +238,8 @@ class ScribeService:
await session.refresh(message) await session.refresh(message)
logger.info( logger.info(
f"Scribe message sent: id={message.id} from={caller.identity} to={recipients}" f"Scribe message sent: id={message.id} from={from_identity} "
f"from_consumer={from_consumer} to={recipients}"
) )
return message return message
@@ -205,26 +257,40 @@ class ScribeService:
limit: int = 50, limit: int = 50,
before_id: Optional[int] = None, before_id: Optional[int] = None,
message_id: Optional[int] = None, message_id: Optional[int] = None,
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]: since_message_id: Optional[int] = None,
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int, Optional[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 `message_id`, when given, is a direct lookup rather than a filtered
list: zero or one entry (the unique constraint on list: zero or one entry (the unique constraint on
(address, message_id) guarantees at most one), in any view — it (address, message_id) guarantees at most one), in any view — it
ignores `view`/`unread`/`tag`/`before_id` entirely, which is the ignores `view`/`unread`/`tag`/`before_id`/`since_message_id`
point (core-api T-9): a client that already knows the message entirely, which is the point (core-api T-9): a client that already
number never pages the unread-first/id-descending listing to find knows the message number never pages the unread-first/id-descending
it. listing to find it.
Otherwise `view` selects which of the independent `archived_at`/ Otherwise `view` selects which of the independent `archived_at`/
`trashed_at` states to show: `inbox` (default) is neither, `trashed_at` states to show: `inbox` (default) is neither,
`archive` is archived and not trashed, `trash` is trashed `archive` is archived and not trashed, `trash` is trashed
(regardless of archived_at — trashing hides an entry from the (regardless of archived_at — trashing hides an entry from the
archive view too), `all` applies no filter on either column. archive view too), `all` applies no filter on either column.
`since_message_id`, when given, keeps only entries whose message
number is above it (strictly — the boundary message itself is not
"since" itself), combined with `view`/`unread`/`tag` the same way
they combine with each other (core-api T-16).
Returns `(results, total, newest_message_id)`. `total` counts
whatever matched the filters above, independent of `limit` — true
before this round too, since the count query already runs against
the pre-`limit` subquery; `since_message_id` becomes one more
filter it counts against, nothing more. `newest_message_id` is
**not** scoped by any of those filters — see `_newest_message_id`.
""" """
tenant = await self._require_tenant(session, caller) tenant = await self._require_tenant(session, caller)
await self._require_owned_identity(session, caller, tenant) await self._require_owned_identity(session, caller, tenant)
newest_message_id = await self._newest_message_id(session, tenant, caller.identity)
if message_id is not None: if message_id is not None:
lookup_query = select(ScribeMailboxEntry).where( lookup_query = select(ScribeMailboxEntry).where(
ScribeMailboxEntry.owner_tenant == tenant, ScribeMailboxEntry.owner_tenant == tenant,
@@ -234,7 +300,7 @@ class ScribeService:
entries = list((await session.execute(lookup_query)).scalars().all()) entries = list((await session.execute(lookup_query)).scalars().all())
messages_by_id = await self._messages_for_entries(session, entries) messages_by_id = await self._messages_for_entries(session, entries)
results = [(entry, messages_by_id[entry.message_id]) for entry in entries] results = [(entry, messages_by_id[entry.message_id]) for entry in entries]
return results, len(results) return results, len(results), newest_message_id
conditions = [ conditions = [
ScribeMailboxEntry.owner_tenant == tenant, ScribeMailboxEntry.owner_tenant == tenant,
@@ -256,6 +322,8 @@ class ScribeService:
conditions.append(ScribeMailboxEntry.tags.contains([tag])) conditions.append(ScribeMailboxEntry.tags.contains([tag]))
if before_id is not None: if before_id is not None:
conditions.append(ScribeMailboxEntry.id < before_id) conditions.append(ScribeMailboxEntry.id < before_id)
if since_message_id is not None:
conditions.append(ScribeMailboxEntry.message_id > since_message_id)
base_query = select(ScribeMailboxEntry).where(*conditions) base_query = select(ScribeMailboxEntry).where(*conditions)
count_query = select(func.count()).select_from(base_query.subquery()) count_query = select(func.count()).select_from(base_query.subquery())
@@ -269,7 +337,7 @@ class ScribeService:
entries = list((await session.execute(query)).scalars().all()) entries = list((await session.execute(query)).scalars().all())
messages_by_id = await self._messages_for_entries(session, entries) messages_by_id = await self._messages_for_entries(session, entries)
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total return [(entry, messages_by_id[entry.message_id]) for entry in entries], total, newest_message_id
async def find_messages( async def find_messages(
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50 self, session: AsyncSession, caller: Caller, q: str, limit: int = 50
+31 -26
View File
@@ -8,15 +8,16 @@ other `POSTGRES_*` values point there too, so this is the one guard that
stands between a test run and the production database. stands between a test run and the production database.
Running the actual migration against the test database is done through the Running the actual migration against the test database is done through the
`alembic` CLI in a subprocess rather than in-process, because `alembic` CLI in a subprocess rather than in-process, because `alembic/env.py`
`alembic/env.py` always builds its connection URL from `get_settings()` builds its connection in a fresh interpreter each time, unaffected by this
(composed from `POSTGRES_*`), with no override hook of its own — and process's `lru_cache`d `get_settings()` — by the time a test runs, other test
`get_settings()` is `lru_cache`d, so by the time a test runs, other test
modules have almost certainly already imported `src.main` and cached a modules have almost certainly already imported `src.main` and cached a
Settings instance pointed at production. A subprocess gets a clean Settings instance pointed at production. `run_alembic` always passes
environment and a clean cache; overriding `POSTGRES_HOST`/`USER`/ `-x target=test` (core-api T-3: alembic refuses to run with no target, or
`PASSWORD`/`DATABASE` in that child's environment, derived from any other one) and sets `TEST_DATABASE_URL` in the child's environment to
`TEST_DATABASE_URL`, is what actually redirects the migration. `url`, which is what actually redirects the migration — the subprocess's own
`alembic/env.py` reads that variable itself; nothing here overrides
`POSTGRES_*` any more.
""" """
import asyncio import asyncio
import os import os
@@ -33,26 +34,13 @@ ALEMBIC_BIN = REPO_ROOT / ".venv" / "bin" / "alembic"
load_dotenv(REPO_ROOT / ".env") load_dotenv(REPO_ROOT / ".env")
def _components(url: str) -> dict:
"""POSTGRES_* overrides for a subprocess, derived from a database URL."""
parts = urlsplit(url)
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
return {
"POSTGRES_HOST": host,
"POSTGRES_USER": parts.username or "",
"POSTGRES_PASSWORD": parts.password or "",
"POSTGRES_DATABASE": parts.path.lstrip("/"),
}
def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess: def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess:
"""Run `alembic <args>` against `url`, never against the repo's own .env database.""" """Run `alembic -x target=test <args>` against `url`, never against the
repo's own .env database."""
env = os.environ.copy() env = os.environ.copy()
env.update(_components(url)) env["TEST_DATABASE_URL"] = url
result = subprocess.run( result = subprocess.run(
[str(ALEMBIC_BIN), *args], [str(ALEMBIC_BIN), "-x", "target=test", *args],
cwd=str(REPO_ROOT), cwd=str(REPO_ROOT),
env=env, env=env,
capture_output=True, capture_output=True,
@@ -61,6 +49,23 @@ def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess:
return result return result
class MaskedURL(str):
"""A database URL, fully usable as a plain string (passed to `.replace()`,
`urlsplit()`, a subprocess environment, ...) but never through `repr()`
(core-api T-12): pytest prints a failing test's fixture arguments using
`repr()`, with no `-v`/`--showlocals` needed to trigger it — that is how
a failing database test used to print this fixture's password straight
into the terminal. `__str__` is deliberately left alone, so the real
value is still what every caller actually connects with."""
def __repr__(self) -> str:
parts = urlsplit(self)
netloc = parts.netloc
if parts.password:
netloc = netloc.replace(f":{parts.password}@", ":***@")
return repr(parts._replace(netloc=netloc).geturl())
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def test_database_url() -> str: def test_database_url() -> str:
"""The core_api_test database URL, or skip/refuse per the rules above.""" """The core_api_test database URL, or skip/refuse per the rules above."""
@@ -74,7 +79,7 @@ def test_database_url() -> str:
"TEST_DATABASE_URL resolves to 'core_api' (production) — refusing to use it for tests" "TEST_DATABASE_URL resolves to 'core_api' (production) — refusing to use it for tests"
) )
return url return MaskedURL(url)
def _grant_test_office_gemini(url: str) -> None: def _grant_test_office_gemini(url: str) -> None:
+49
View File
@@ -0,0 +1,49 @@
"""Tests for core-api T-3: alembic refuses to run without a named target.
`alembic/env.py` resolves `-x target=test` or `-x target=production`; no
target, or anything else, must exit non-zero before any engine is built —
not skip, not fall back to the production settings a bare `alembic current`
used to reach by accident. Both tests point the *production* settings
(`POSTGRES_*`) at an unreachable host: if the refusal ever started
connecting before checking the target, these would hang (and the
subprocess timeout below would fire) instead of returning quickly.
"""
import os
import subprocess
from tests.conftest import ALEMBIC_BIN, REPO_ROOT
UNREACHABLE_HOST = "10.255.255.1" # non-routed; a real connection attempt would hang
def _run_with_unreachable_production(*args: str) -> subprocess.CompletedProcess:
env = os.environ.copy()
env["POSTGRES_HOST"] = UNREACHABLE_HOST
env["POSTGRES_USER"] = "probe"
env["POSTGRES_PASSWORD"] = "probe"
env["POSTGRES_DATABASE"] = "probe"
env.pop("TEST_DATABASE_URL", None)
return subprocess.run(
[str(ALEMBIC_BIN), *args],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=10, # a real connection attempt to an unreachable host hangs well past this
)
def test_no_target_refuses_before_connecting():
result = _run_with_unreachable_production("current")
assert result.returncode != 0
combined = result.stdout + result.stderr
assert "target=test" in combined
assert "target=production" in combined
def test_nonsense_target_refuses_before_connecting():
result = _run_with_unreachable_production("-x", "target=nonsense", "current")
assert result.returncode != 0
combined = result.stdout + result.stderr
assert "target=test" in combined
assert "target=production" in combined
+2 -2
View File
@@ -65,8 +65,8 @@ class TestMigration005RoundTrip:
assert not await _table_exists(test_database_url, "book_events") assert not await _table_exists(test_database_url, "book_events")
assert not await _table_exists(test_database_url, "book_tenant_counters") assert not await _table_exists(test_database_url, "book_tenant_counters")
# Leave the schema at head for the rest of the suite. # Leave the schema at head for the rest of the suite (core-api T-14).
up = run_alembic(test_database_url, "upgrade", "005") up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr assert up.returncode == 0, up.stderr
assert await _table_exists(test_database_url, "book_records") assert await _table_exists(test_database_url, "book_records")
assert await _table_exists(test_database_url, "book_events") assert await _table_exists(test_database_url, "book_events")
+43
View File
@@ -0,0 +1,43 @@
"""Tests for conftest.py's own test-infrastructure fixtures.
core-api T-12: a failing database test used to print the test database
password, because pytest renders a failing test's fixture arguments with
`repr()` — no `-v` or `--showlocals` needed to trigger it, just a test that
takes `test_database_url` and fails. `MaskedURL` (conftest.py) is the fix:
same string, same connection, but a `repr()` that never shows the password.
"""
from urllib.parse import urlsplit
from tests.conftest import MaskedURL
def test_database_url_fixture_renders_without_its_password(test_database_url):
"""Mutation check: revert `MaskedURL.__repr__` to the inherited `str`
one (or hand back a plain `str` from the fixture) and this fails — the
real password would appear in `repr(test_database_url)`, exactly as it
did in a failing test's traceback before T-12."""
password = urlsplit(test_database_url).password
assert password, "fixture must carry a real password for this test to mean anything"
rendered = repr(test_database_url)
assert password not in rendered
# Host and database name are still visible — only the password is masked.
assert "***" in rendered
assert urlsplit(test_database_url).path in rendered
def test_masked_url_is_still_a_fully_usable_string():
"""The wrapper changes only `repr()`; every other string operation the
rest of the suite relies on (`.replace()`, `urlsplit()`, use as a
subprocess environment value) must see the real value unchanged."""
raw = "postgresql://core_api_test:s3cr3t@localhost:5432/core_api_test"
masked = MaskedURL(raw)
assert masked == raw
assert str(masked) == raw
assert masked.replace("postgresql://", "postgresql+asyncpg://", 1) == raw.replace(
"postgresql://", "postgresql+asyncpg://", 1
)
assert urlsplit(masked).password == "s3cr3t"
assert "s3cr3t" not in repr(masked)
+40
View File
@@ -0,0 +1,40 @@
"""Tests for core-api T-3's `make dev-test-server` target: it must refuse to
start when the resolved database is not `core_api_test`, before uvicorn ever
binds the port, and it must never print credentials while doing so."""
import os
import socket
import subprocess
from urllib.parse import urlsplit
from tests.conftest import REPO_ROOT
PORT = 8777
def _port_is_free(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(("localhost", port)) != 0
def test_refuses_when_resolved_database_is_not_core_api_test(test_database_url):
password = urlsplit(test_database_url).password
wrong = test_database_url.rsplit("/", 1)[0] + "/core_api"
env = os.environ.copy()
env["TEST_DATABASE_URL"] = wrong
result = subprocess.run(
["make", "dev-test-server"],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
text=True,
timeout=15,
)
combined = result.stdout + result.stderr
assert result.returncode != 0
assert "core_api_test" in combined
if password:
assert password not in combined
assert _port_is_free(PORT), "dev-test-server must never bind the port when it refuses"
+348
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 engine/session per call because TestClient drives the ASGI app on a
different event loop than pytest-asyncio's fixture setup. different event loop than pytest-asyncio's fixture setup.
""" """
from unittest.mock import patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -68,6 +70,17 @@ def office_headers(identity: str, consumer: str, tenant: str = TENANT) -> dict:
} }
def office_headers_no_identity(consumer: str, tenant: str = TENANT) -> dict:
"""No `X-Office-Identity` at all — core-api T-15's one exemption (an
office caller's `feedback` to `tower-control` alone) is reachable only
through this shape; every other write still needs the header."""
return {
"Authorization": f"Bearer {OFFICE_KEY}",
"X-Office-Consumer": consumer,
"X-Office-Tenant": tenant,
}
def register(client, identity: str, consumer: str, tenant: str = TENANT): def register(client, identity: str, consumer: str, tenant: str = TENANT):
return client.post( return client.post(
"/scribe/addresses", "/scribe/addresses",
@@ -403,6 +416,121 @@ class TestSendValidation:
assert reply.json()["detail"]["code"] == "not_found" assert reply.json()["detail"]["code"] == "not_found"
# =============================================================================
# `feedback` needs no address (core-api T-15 / workspace D-70): the one
# shape that may ship with no sender is an office caller's `feedback` to
# `tower-control` alone — nothing broader.
# =============================================================================
class TestFeedbackWithoutIdentity:
def test_feedback_to_tower_control_alone_with_no_identity_succeeds(self, client):
register(client, "tower-control", "office-gemini")
sent = client.post(
"/scribe/messages",
json={
"to": ["tower-control"],
"body": "the find verb has no limit guard",
"tags": ["feedback"],
},
headers=office_headers_no_identity("office-claude"),
)
assert sent.status_code == 201
message = sent.json()
assert message["from_identity"] is None
assert message["from_consumer"] == "office-claude"
assert message["topics"] == ["tower-control"]
assert message["tags"] == ["feedback"]
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox["total"] == 1
assert mailbox["entries"][0]["message"]["from_identity"] is None
assert mailbox["entries"][0]["message"]["id"] == message["id"]
history = client.get(
"/scribe/addresses/tower-control/history",
headers=office_headers("tower-control", "office-gemini"),
).json()
assert history["messages"][0]["from_identity"] is None
found = client.get(
"/scribe/messages",
params={"q": "limit"},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["from_identity"] is None
def test_feedback_with_no_identity_still_needs_a_known_tenant(self, client):
"""No sender is not no tenant (the brief): `_require_tenant` keeps
running on this path, ahead of the identity decision."""
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
headers=office_headers_no_identity("office-claude", tenant="no-such-tenant"),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "tenant_unknown"
def test_feedback_with_no_identity_still_needs_a_granted_consumer(self, client):
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
headers=office_headers_no_identity("office-ungranted"),
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "consumer_not_granted"
@pytest.mark.parametrize(
"to,tags",
[
(["tower-control", "jeroen"], ["feedback"]), # a second recipient
(["jeroen"], ["feedback"]), # another recipient, not tower-control
(["tower-control"], []), # no feedback tag
(["tower-control"], ["urgent"]), # tagged, but not feedback
],
ids=[
"second-recipient",
"another-recipient",
"no-feedback-tag",
"tagged-but-not-feedback",
],
)
def test_every_broader_shape_with_no_identity_is_still_400(self, client, to, tags):
response = client.post(
"/scribe/messages",
json={"to": to, "body": "x", "tags": tags},
headers=office_headers_no_identity("office-claude"),
)
assert response.status_code == 400
assert response.json()["detail"]["code"] == "identity_required"
def test_user_kind_caller_gains_nothing_from_the_exemption(self, client):
"""A user-kind caller's identity is never empty (callers.py always
falls back to a name, never ""), so it never reaches the
no-identity branch at all: sending this exact shape is unaffected
by T-15 and still needs an owned address, same as before. Not 400
identity_required — that code names an office caller resolved with
none; this is 403 identity_not_owned, because "jeroen" was never
registered under this (consumer-less) user identity."""
register(client, "tower-control", "office-gemini")
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
mock_get_current_user.return_value = {
"preferred_username": "jeroen",
"tatlock_user": TENANT,
}
response = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "x", "tags": ["feedback"]},
headers={"Authorization": "Bearer some-jwt"},
)
assert response.status_code == 403
assert response.json()["detail"]["code"] == "identity_not_owned"
# ============================================================================= # =============================================================================
# Isolation (dossier verification #3): the hard tenant boundary, both # Isolation (dossier verification #3): the hard tenant boundary, both
# directions — every cross-tenant answer is indistinguishable from "does # directions — every cross-tenant answer is indistinguishable from "does
@@ -915,3 +1043,223 @@ class TestMailboxMessageIdLookup:
).json() ).json()
assert found["total"] == 1 assert found["total"] == 1
assert found["entries"][0]["message"]["id"] == target_message_id assert found["entries"][0]["message"]["id"] == target_message_id
# =============================================================================
# since_message_id and newest_message_id (core-api T-16): the Office's one
# polling call, `glance`, answers "has anything changed since I last
# looked?" with these.
# =============================================================================
class TestSinceMessageId:
def test_since_message_id_returns_only_entries_above_it(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
ids = []
for body in ("one", "two", "three"):
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": body},
headers=office_headers("jeroen", "office-claude"),
).json()
ids.append(sent["id"])
mailbox = client.get(
"/scribe/mailbox",
params={"since_message_id": ids[0]},
headers=office_headers("tower-control", "office-gemini"),
).json()
returned_ids = {e["message"]["id"] for e in mailbox["entries"]}
assert returned_ids == {ids[1], ids[2]}
assert mailbox["total"] == 2
def test_since_message_id_boundary_excludes_the_named_message(self, client):
"""dossier mutation check #1: `>=` would include message N itself;
`since_message_id=N` means strictly above N."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "the boundary message"},
headers=office_headers("jeroen", "office-claude"),
).json()
mailbox = client.get(
"/scribe/mailbox",
params={"since_message_id": sent["id"]},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert mailbox["entries"] == []
assert mailbox["total"] == 0
def test_since_message_id_combines_with_unread_and_a_view(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
first = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "first, will be read"},
headers=office_headers("jeroen", "office-claude"),
).json()
second = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "second, stays unread"},
headers=office_headers("jeroen", "office-claude"),
).json()
first_entry_id = client.get(
"/scribe/mailbox",
params={"message_id": first["id"]},
headers=office_headers("tower-control", "office-gemini"),
).json()["entries"][0]["id"]
client.patch(
f"/scribe/mailbox/{first_entry_id}",
json={"read": True},
headers=office_headers("tower-control", "office-gemini"),
)
mailbox = client.get(
"/scribe/mailbox",
params={"since_message_id": 0, "unread": True, "view": "inbox"},
headers=office_headers("tower-control", "office-gemini"),
).json()
returned_ids = {e["message"]["id"] for e in mailbox["entries"]}
assert returned_ids == {second["id"]}
def test_total_counts_every_match_independent_of_limit(self, client):
"""dossier verification #2: `unread=true&limit=1` gives the unread
count in one row via `total`."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
for body in ("a", "b", "c"):
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": body},
headers=office_headers("jeroen", "office-claude"),
)
mailbox = client.get(
"/scribe/mailbox",
params={"unread": True, "limit": 1},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert len(mailbox["entries"]) == 1
assert mailbox["total"] == 3
def test_message_id_lookup_ignores_since_message_id(self, client):
"""`message_id` already bypasses view/unread/tag; `since_message_id`
keeps that behavior (dossier build item #1)."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "direct lookup"},
headers=office_headers("jeroen", "office-claude"),
).json()
found = client.get(
"/scribe/mailbox",
params={"message_id": sent["id"], "since_message_id": sent["id"]},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["id"] == sent["id"]
class TestNewestMessageId:
def test_newest_message_id_is_true_newest_when_an_older_message_is_unread(self, client):
"""dossier core-api T-16, the exact case the Office hit: the
unread-first sort makes `limit=1` return an older unread message
when a newer one is already read. `newest_message_id` must answer
with the newer one regardless (dossier mutation check #2)."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
older = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "older, stays unread"},
headers=office_headers("jeroen", "office-claude"),
).json()
newer = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "newer, gets read"},
headers=office_headers("jeroen", "office-claude"),
).json()
newer_entry_id = client.get(
"/scribe/mailbox",
params={"message_id": newer["id"]},
headers=office_headers("tower-control", "office-gemini"),
).json()["entries"][0]["id"]
client.patch(
f"/scribe/mailbox/{newer_entry_id}",
json={"read": True},
headers=office_headers("tower-control", "office-gemini"),
)
page = client.get(
"/scribe/mailbox",
params={"limit": 1},
headers=office_headers("tower-control", "office-gemini"),
).json()
# unread-first sort: the older, still-unread message leads page one —
# the condition newest_message_id exists to see past.
assert page["entries"][0]["message"]["id"] == older["id"]
assert page["newest_message_id"] == newer["id"]
def test_newest_message_id_is_null_for_an_empty_mailbox(self, client):
register(client, "tower-control", "office-gemini")
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox["entries"] == []
assert mailbox["newest_message_id"] is None
def test_newest_message_id_never_crosses_an_address_boundary(self, client):
"""dossier mutation check #3: dropping the address filter would let
a later message delivered to a different address in the same
tenant — a higher message number that never reaches this
identity's mailbox — leak into its newest_message_id."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
register(client, "ops", "office-claude")
mine = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "mine"},
headers=office_headers("jeroen", "office-claude"),
).json()
client.post(
"/scribe/messages",
json={"to": ["ops"], "body": "not mine, later and higher id"},
headers=office_headers("jeroen", "office-claude"),
)
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox["newest_message_id"] == mine["id"]
def test_newest_message_id_never_crosses_a_tenant_boundary(self, client):
"""Same concern as the address boundary above, across tenants
(D-73's hard boundary)."""
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
mine = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "for the TENANT tower-control"},
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
).json()
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "for the OTHER_TENANT tower-control, later/higher id"},
headers=office_headers("jeroen", "office-claude", tenant=OTHER_TENANT),
)
mailbox = client.get(
"/scribe/mailbox",
headers=office_headers("tower-control", "office-gemini", TENANT),
).json()
assert mailbox["newest_message_id"] == mine["id"]
+105 -3
View File
@@ -1,5 +1,5 @@
""" """
Tests for Scribe migrations 006 and 009. Tests for Scribe migrations 006, 009 and 010.
Runs against `core_api_test` only (see conftest.py — skip when Runs against `core_api_test` only (see conftest.py — skip when
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`). TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
@@ -27,6 +27,57 @@ async def _table_exists(url: str, table: str) -> bool:
await engine.dispose() await engine.dispose()
async def _is_nullable(url: str, table: str, column: str) -> bool:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.connect() as conn:
result = await conn.execute(
text(
"SELECT is_nullable FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() == "YES"
finally:
await engine.dispose()
async def _clear_scribe_messages(url: str) -> None:
"""Truncate just the two row-bearing Scribe tables that 010's downgrade
inspects — never `scribe_addresses`/`tenant_consumers`, which other
modules' seeded grants depend on. Self-contained so this file's
row-level checks never depend on what another test module's data left
behind (there is no FK from either table to `scribe_addresses`)."""
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.begin() as conn:
await conn.execute(text("TRUNCATE scribe_mailbox_entries, scribe_messages"))
finally:
await engine.dispose()
async def _insert_sender_less_message(url: str) -> None:
"""Insert one scribe_messages row with `from_identity` NULL directly —
bypassing the API, which (even under T-15's exemption) always exists
for the one shape it allows; this proves the migration's own
constraint/refusal, not the service's."""
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
try:
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO scribe_messages (owner_tenant, topics, from_identity, body) "
"VALUES ('jpmschweitzer', ARRAY['tower-control'], NULL, 'no sender')"
)
)
finally:
await engine.dispose()
async def _columns(url: str, table: str) -> set: async def _columns(url: str, table: str) -> set:
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1) asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
engine = create_async_engine(asyncpg_url, poolclass=NullPool) engine = create_async_engine(asyncpg_url, poolclass=NullPool)
@@ -69,8 +120,8 @@ class TestMigration006RoundTrip:
# a downgrade of 006 — proves 006 only touches its own tables. # a downgrade of 006 — proves 006 only touches its own tables.
assert await _table_exists(test_database_url, "book_records") assert await _table_exists(test_database_url, "book_records")
# Leave the schema at head for the rest of the suite. # Leave the schema at head for the rest of the suite (core-api T-14).
up = run_alembic(test_database_url, "upgrade", "006") up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr assert up.returncode == 0, up.stderr
assert await _table_exists(test_database_url, "scribe_addresses") assert await _table_exists(test_database_url, "scribe_addresses")
assert await _table_exists(test_database_url, "scribe_messages") assert await _table_exists(test_database_url, "scribe_messages")
@@ -108,3 +159,54 @@ class TestMigration009RoundTrip:
assert up.returncode == 0, up.stderr assert up.returncode == 0, up.stderr
columns_at_head = await _columns(test_database_url, "scribe_mailbox_entries") columns_at_head = await _columns(test_database_url, "scribe_mailbox_entries")
assert {"archived_at", "trashed_at"} <= columns_at_head 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
+30 -6
View File
@@ -67,8 +67,8 @@ class TestMigration007RoundTrip:
assert await _table_exists(test_database_url, "book_records") assert await _table_exists(test_database_url, "book_records")
assert await _table_exists(test_database_url, "scribe_addresses") assert await _table_exists(test_database_url, "scribe_addresses")
# Leave the schema at head for the rest of the suite. # Leave the schema at head for the rest of the suite (core-api T-14).
up = run_alembic(test_database_url, "upgrade", "007") up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr assert up.returncode == 0, up.stderr
assert await _table_exists(test_database_url, "tenants") assert await _table_exists(test_database_url, "tenants")
assert await _table_exists(test_database_url, "tenant_consumers") assert await _table_exists(test_database_url, "tenant_consumers")
@@ -224,8 +224,8 @@ class TestMigration008RoundTrip:
assert await _table_exists(test_database_url, "tenant_consumers") assert await _table_exists(test_database_url, "tenant_consumers")
assert await _table_exists(test_database_url, "tenants") assert await _table_exists(test_database_url, "tenants")
# Leave the schema at head for the rest of the suite. # Leave the schema at head for the rest of the suite (core-api T-14).
up = run_alembic(test_database_url, "upgrade", "008") up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr assert up.returncode == 0, up.stderr
found = await _scalar( found = await _scalar(
test_database_url, test_database_url,
@@ -312,7 +312,10 @@ class TestMigration008DataStep:
down = run_alembic(test_database_url, "downgrade", "007") down = run_alembic(test_database_url, "downgrade", "007")
assert down.returncode == 0, down.stderr assert down.returncode == 0, down.stderr
up = run_alembic(test_database_url, "upgrade", "008") # Upgrade through to head (core-api T-14) — migrations 009/010 touch
# neither this table nor this column, so the assertion below still
# proves 008's data step alone.
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr assert up.returncode == 0, up.stderr
found = await _scalar( found = await _scalar(
test_database_url, test_database_url,
@@ -356,7 +359,9 @@ class TestMigration008DataStep:
) )
) )
up = run_alembic(test_database_url, "upgrade", "008") # Upgrade through to head (core-api T-14) — same reasoning as
# test_is_a_noop_when_claude_ai_is_absent above.
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr assert up.returncode == 0, up.stderr
found = await _scalar( found = await _scalar(
@@ -380,3 +385,22 @@ class TestMigration008DataStep:
) )
) )
await engine.dispose() await engine.dispose()
class TestSchemaEndsAtHeadAfterTheMigrationSuite:
"""core-api T-14 regression guard: every round-trip test above (and in
test_book_migration.py / test_scribe_migration.py, which this file's
alphabetically-later collection runs after) must leave `core_api_test`
at head, not on its own revision. This class is the last one defined in
the last-collected migration test file, and its check is a bare
`current` — no `upgrade head` of its own — so a round-trip test that
regresses to ending on its own revision again shows up here as "not
head" rather than being silently repaired by this very check."""
@pytest.mark.asyncio
async def test_schema_is_at_head(self, test_database_url):
result = run_alembic(test_database_url, "current")
assert result.returncode == 0, result.stderr
assert "(head)" in result.stdout, (
f"core_api_test is not at head after the migration suite: {result.stdout!r}"
)