Compare commits

...
8 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
19 changed files with 756 additions and 54 deletions
+57
View File
@@ -162,3 +162,60 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.
**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;
+1
View File
@@ -13,3 +13,4 @@ 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 ('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;
+60
View File
@@ -199,3 +199,63 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
**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;
+37
View File
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
+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
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
**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
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:
```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; }
$(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
# 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
+75 -8
View File
@@ -2,18 +2,30 @@
Alembic Environment Configuration
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 os
import re
import sys
from logging.config import fileConfig
from pathlib import Path
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 alembic import context
# Import our models and config
from src.shared.config import get_settings
# Import our models (config is imported lazily, inside _production_url, so
# that resolving a bad or missing target never has to construct Settings).
from src.shared.database import Base
# 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
REPO_ROOT = Path(__file__).resolve().parent.parent
# Alembic Config object
config = context.config
@@ -37,11 +51,64 @@ if config.config_file_name is not None:
# Model metadata for autogenerate support
target_metadata = Base.metadata
# Get database URL from our settings
settings = get_settings()
db_url = settings.database_url
if db_url.startswith("postgresql://"):
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
def _test_url() -> str:
"""TEST_DATABASE_URL from the environment, else the repo's own `.env` —
read that one variable only; the file is never loaded or sourced whole."""
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:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "core-api"
version = "1.15.0"
version = "1.16.0"
description = "Core Code API - Infrastructure management and tools API"
readme = "README.md"
requires-python = ">=3.12"
+9 -2
View File
@@ -196,13 +196,18 @@ class ScribeController(BaseController):
message_id: Optional[int] = Query(
None,
description="look up the caller's own entry for this message number directly, "
"in any view, ignoring unread/tag/before_id (core-api T-9)",
"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),
session: AsyncSession = Depends(get_async_session),
):
try:
results, total = await service.get_mailbox(
results, total, newest_message_id = await service.get_mailbox(
session,
caller,
view=view,
@@ -211,6 +216,7 @@ class ScribeController(BaseController):
limit=limit,
before_id=before_id,
message_id=message_id,
since_message_id=since_message_id,
)
except ScribeError as error:
raise HTTPException(
@@ -220,6 +226,7 @@ class ScribeController(BaseController):
entries=[_entry_response(entry, message) for entry, message in results],
total=total,
limit=limit,
newest_message_id=newest_message_id,
)
@router.patch(
+8
View File
@@ -78,6 +78,14 @@ class MailboxListResponse(BaseSchema):
entries: List[MailboxEntryResponse]
total: 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):
+39 -7
View File
@@ -103,6 +103,22 @@ class ScribeService:
messages = (await session.execute(stmt)).scalars().all()
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
# =========================================================================
@@ -241,26 +257,40 @@ class ScribeService:
limit: int = 50,
before_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.
`message_id`, when given, is a direct lookup rather than a filtered
list: zero or one entry (the unique constraint on
(address, message_id) guarantees at most one), in any view — it
ignores `view`/`unread`/`tag`/`before_id` entirely, which is the
point (core-api T-9): a client that already knows the message
number never pages the unread-first/id-descending listing to find
it.
ignores `view`/`unread`/`tag`/`before_id`/`since_message_id`
entirely, which is the point (core-api T-9): a client that already
knows the message number never pages the unread-first/id-descending
listing to find it.
Otherwise `view` selects which of the independent `archived_at`/
`trashed_at` states to show: `inbox` (default) is neither,
`archive` is archived and not trashed, `trash` is trashed
(regardless of archived_at — trashing hides an entry from the
archive view too), `all` applies no filter on either column.
`since_message_id`, when given, keeps only entries whose message
number is above it (strictly — the boundary message itself is not
"since" itself), combined with `view`/`unread`/`tag` the same way
they combine with each other (core-api T-16).
Returns `(results, total, newest_message_id)`. `total` counts
whatever matched the filters above, independent of `limit` — true
before this round too, since the count query already runs against
the pre-`limit` subquery; `since_message_id` becomes one more
filter it counts against, nothing more. `newest_message_id` is
**not** scoped by any of those filters — see `_newest_message_id`.
"""
tenant = await self._require_tenant(session, caller)
await self._require_owned_identity(session, caller, tenant)
newest_message_id = await self._newest_message_id(session, tenant, caller.identity)
if message_id is not None:
lookup_query = select(ScribeMailboxEntry).where(
ScribeMailboxEntry.owner_tenant == tenant,
@@ -270,7 +300,7 @@ class ScribeService:
entries = list((await session.execute(lookup_query)).scalars().all())
messages_by_id = await self._messages_for_entries(session, entries)
results = [(entry, messages_by_id[entry.message_id]) for entry in entries]
return results, len(results)
return results, len(results), newest_message_id
conditions = [
ScribeMailboxEntry.owner_tenant == tenant,
@@ -292,6 +322,8 @@ class ScribeService:
conditions.append(ScribeMailboxEntry.tags.contains([tag]))
if before_id is not None:
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)
count_query = select(func.count()).select_from(base_query.subquery())
@@ -305,7 +337,7 @@ class ScribeService:
entries = list((await session.execute(query)).scalars().all())
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(
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.
Running the actual migration against the test database is done through the
`alembic` CLI in a subprocess rather than in-process, because
`alembic/env.py` always builds its connection URL from `get_settings()`
(composed from `POSTGRES_*`), with no override hook of its own — and
`get_settings()` is `lru_cache`d, so by the time a test runs, other test
`alembic` CLI in a subprocess rather than in-process, because `alembic/env.py`
builds its connection in a fresh interpreter each time, unaffected by this
process's `lru_cache`d `get_settings()` — by the time a test runs, other test
modules have almost certainly already imported `src.main` and cached a
Settings instance pointed at production. A subprocess gets a clean
environment and a clean cache; overriding `POSTGRES_HOST`/`USER`/
`PASSWORD`/`DATABASE` in that child's environment, derived from
`TEST_DATABASE_URL`, is what actually redirects the migration.
Settings instance pointed at production. `run_alembic` always passes
`-x target=test` (core-api T-3: alembic refuses to run with no target, or
any other one) and sets `TEST_DATABASE_URL` in the child's environment to
`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 os
@@ -33,26 +34,13 @@ ALEMBIC_BIN = REPO_ROOT / ".venv" / "bin" / "alembic"
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:
"""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.update(_components(url))
env["TEST_DATABASE_URL"] = url
result = subprocess.run(
[str(ALEMBIC_BIN), *args],
[str(ALEMBIC_BIN), "-x", "target=test", *args],
cwd=str(REPO_ROOT),
env=env,
capture_output=True,
@@ -61,6 +49,23 @@ def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess:
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")
def test_database_url() -> str:
"""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"
)
return url
return MaskedURL(url)
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_tenant_counters")
# Leave the schema at head for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "005")
# Leave the schema at head for the rest of the suite (core-api T-14).
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr
assert await _table_exists(test_database_url, "book_records")
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"
+220
View File
@@ -1043,3 +1043,223 @@ class TestMailboxMessageIdLookup:
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["id"] == target_message_id
# =============================================================================
# since_message_id and newest_message_id (core-api T-16): the Office's one
# polling call, `glance`, answers "has anything changed since I last
# looked?" with these.
# =============================================================================
class TestSinceMessageId:
def test_since_message_id_returns_only_entries_above_it(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
ids = []
for body in ("one", "two", "three"):
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": body},
headers=office_headers("jeroen", "office-claude"),
).json()
ids.append(sent["id"])
mailbox = client.get(
"/scribe/mailbox",
params={"since_message_id": ids[0]},
headers=office_headers("tower-control", "office-gemini"),
).json()
returned_ids = {e["message"]["id"] for e in mailbox["entries"]}
assert returned_ids == {ids[1], ids[2]}
assert mailbox["total"] == 2
def test_since_message_id_boundary_excludes_the_named_message(self, client):
"""dossier mutation check #1: `>=` would include message N itself;
`since_message_id=N` means strictly above N."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "the boundary message"},
headers=office_headers("jeroen", "office-claude"),
).json()
mailbox = client.get(
"/scribe/mailbox",
params={"since_message_id": sent["id"]},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert mailbox["entries"] == []
assert mailbox["total"] == 0
def test_since_message_id_combines_with_unread_and_a_view(self, client):
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
first = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "first, will be read"},
headers=office_headers("jeroen", "office-claude"),
).json()
second = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "second, stays unread"},
headers=office_headers("jeroen", "office-claude"),
).json()
first_entry_id = client.get(
"/scribe/mailbox",
params={"message_id": first["id"]},
headers=office_headers("tower-control", "office-gemini"),
).json()["entries"][0]["id"]
client.patch(
f"/scribe/mailbox/{first_entry_id}",
json={"read": True},
headers=office_headers("tower-control", "office-gemini"),
)
mailbox = client.get(
"/scribe/mailbox",
params={"since_message_id": 0, "unread": True, "view": "inbox"},
headers=office_headers("tower-control", "office-gemini"),
).json()
returned_ids = {e["message"]["id"] for e in mailbox["entries"]}
assert returned_ids == {second["id"]}
def test_total_counts_every_match_independent_of_limit(self, client):
"""dossier verification #2: `unread=true&limit=1` gives the unread
count in one row via `total`."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
for body in ("a", "b", "c"):
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": body},
headers=office_headers("jeroen", "office-claude"),
)
mailbox = client.get(
"/scribe/mailbox",
params={"unread": True, "limit": 1},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert len(mailbox["entries"]) == 1
assert mailbox["total"] == 3
def test_message_id_lookup_ignores_since_message_id(self, client):
"""`message_id` already bypasses view/unread/tag; `since_message_id`
keeps that behavior (dossier build item #1)."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
sent = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "direct lookup"},
headers=office_headers("jeroen", "office-claude"),
).json()
found = client.get(
"/scribe/mailbox",
params={"message_id": sent["id"], "since_message_id": sent["id"]},
headers=office_headers("tower-control", "office-gemini"),
).json()
assert found["total"] == 1
assert found["entries"][0]["message"]["id"] == sent["id"]
class TestNewestMessageId:
def test_newest_message_id_is_true_newest_when_an_older_message_is_unread(self, client):
"""dossier core-api T-16, the exact case the Office hit: the
unread-first sort makes `limit=1` return an older unread message
when a newer one is already read. `newest_message_id` must answer
with the newer one regardless (dossier mutation check #2)."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
older = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "older, stays unread"},
headers=office_headers("jeroen", "office-claude"),
).json()
newer = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "newer, gets read"},
headers=office_headers("jeroen", "office-claude"),
).json()
newer_entry_id = client.get(
"/scribe/mailbox",
params={"message_id": newer["id"]},
headers=office_headers("tower-control", "office-gemini"),
).json()["entries"][0]["id"]
client.patch(
f"/scribe/mailbox/{newer_entry_id}",
json={"read": True},
headers=office_headers("tower-control", "office-gemini"),
)
page = client.get(
"/scribe/mailbox",
params={"limit": 1},
headers=office_headers("tower-control", "office-gemini"),
).json()
# unread-first sort: the older, still-unread message leads page one —
# the condition newest_message_id exists to see past.
assert page["entries"][0]["message"]["id"] == older["id"]
assert page["newest_message_id"] == newer["id"]
def test_newest_message_id_is_null_for_an_empty_mailbox(self, client):
register(client, "tower-control", "office-gemini")
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox["entries"] == []
assert mailbox["newest_message_id"] is None
def test_newest_message_id_never_crosses_an_address_boundary(self, client):
"""dossier mutation check #3: dropping the address filter would let
a later message delivered to a different address in the same
tenant — a higher message number that never reaches this
identity's mailbox — leak into its newest_message_id."""
register(client, "jeroen", "office-claude")
register(client, "tower-control", "office-gemini")
register(client, "ops", "office-claude")
mine = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "mine"},
headers=office_headers("jeroen", "office-claude"),
).json()
client.post(
"/scribe/messages",
json={"to": ["ops"], "body": "not mine, later and higher id"},
headers=office_headers("jeroen", "office-claude"),
)
mailbox = client.get(
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
).json()
assert mailbox["newest_message_id"] == mine["id"]
def test_newest_message_id_never_crosses_a_tenant_boundary(self, client):
"""Same concern as the address boundary above, across tenants
(D-73's hard boundary)."""
register(client, "jeroen", "office-claude", tenant=TENANT)
register(client, "tower-control", "office-gemini", tenant=TENANT)
mine = client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "for the TENANT tower-control"},
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
).json()
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
client.post(
"/scribe/messages",
json={"to": ["tower-control"], "body": "for the OTHER_TENANT tower-control, later/higher id"},
headers=office_headers("jeroen", "office-claude", tenant=OTHER_TENANT),
)
mailbox = client.get(
"/scribe/mailbox",
headers=office_headers("tower-control", "office-gemini", TENANT),
).json()
assert mailbox["newest_message_id"] == mine["id"]
+2 -2
View File
@@ -120,8 +120,8 @@ class TestMigration006RoundTrip:
# a downgrade of 006 — proves 006 only touches its own tables.
assert await _table_exists(test_database_url, "book_records")
# Leave the schema at head for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "006")
# Leave the schema at head for the rest of the suite (core-api T-14).
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr
assert await _table_exists(test_database_url, "scribe_addresses")
assert await _table_exists(test_database_url, "scribe_messages")
+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, "scribe_addresses")
# Leave the schema at head for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "007")
# Leave the schema at head for the rest of the suite (core-api T-14).
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr
assert await _table_exists(test_database_url, "tenants")
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, "tenants")
# Leave the schema at head for the rest of the suite.
up = run_alembic(test_database_url, "upgrade", "008")
# Leave the schema at head for the rest of the suite (core-api T-14).
up = run_alembic(test_database_url, "upgrade", "head")
assert up.returncode == 0, up.stderr
found = await _scalar(
test_database_url,
@@ -312,7 +312,10 @@ class TestMigration008DataStep:
down = run_alembic(test_database_url, "downgrade", "007")
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
found = await _scalar(
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
found = await _scalar(
@@ -380,3 +385,22 @@ class TestMigration008DataStep:
)
)
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}"
)