Compare commits
@@ -162,3 +162,102 @@ 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;
|
||||
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.
|
||||
|
||||
**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).', '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).
|
||||
|
||||
Live in core-api 1.16.0 (2026-10-20). Round core-api-T-16-001: 592 passed; three mutation checks each failed their test.', NULL, '2026-10-19 22:57:59', '2026-10-19 22:57:59.557', '2026-10-19 22:57:59.557', NULL, '011e44ad3eabf5be6e430de69ffcb0ec', 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', 'status', 'backlog', 'done', NULL, '2026-10-19 22:57:59', '2026-10-19 22:57:59.699', '2026-10-19 22:57:59.699', NULL, '9713d98606591e2aa77a3ed92907e726', 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 ('06GNG6WQ0V4TY8H403F0T4NWY0', 'description', 'Found answering claude-design (Scribe message 28): a message''s subject is capped at 500 characters, but its body, tag count and tag length are not capped at all, so nothing refuses a huge body — and every reader pays for a body in context. Proposal, awaiting the user (D-74): cap the body (64 KB?) and tags (at most 10, 63 characters each, as the Book does), refusing past it with a code the Office turns into a sentence saying where big things go (an artifact, later the file store).', 'Found answering claude-design (Scribe message 28): a message''s subject is capped at 500 characters, but its body, tag count and tag length are not capped at all, so nothing refuses a huge body — and every reader pays for a body in context. Proposal, awaiting the user (D-74): cap the body (64 KB?) and tags (at most 10, 63 characters each, as the Book does), refusing past it with a code the Office turns into a sentence saying where big things go (an artifact, later the file store).
|
||||
|
||||
From claude-design (Scribe message 32): if the caps are adopted, the refusal names the artifact asset route as the place big things go, until a core-api file store exists.', NULL, '2026-10-20 07:04:16', '2026-10-20 07:04:16.165', '2026-10-20 07:04:16.165', NULL, 'f917cfdefbfe3fd5e2488bb4e466fdaa', 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.
|
||||
|
||||
**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.', '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.
|
||||
|
||||
**v1 slice live in core-api 1.17.0** (2026-10-20; round core-api-T-2-001, b4197a1 and e249df0; 633 passed; production migrated to 011 with make migrate-production). Remaining on T-2: remind, reschedule, snooze, pause/resume, undo, purge, redact, batch routes.', NULL, '2026-10-20 07:24:16', '2026-10-20 07:24:16.383', '2026-10-20 07:24:16.383', NULL, '5771ac4e71920233d5d1d494f4873eca', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -13,3 +13,7 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('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;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GNG6WQ0V4TY8H403F0T4NWY0', 'T-17', '2026-10-20 06:59:25.574', '2026-10-20 06:59:25.574', NULL, 'b51d615f9a00c6097690b3515915a624', 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 ('06GNGCJQ0KKD2RJQD1RYM0YRV4', 'T-18', '2026-10-20 07:24:16.516', '2026-10-20 07:24:16.516', NULL, 'e65930e1b2c9688c7052c2a7e67f91fa', 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 ('06GNGCJQF7KZ8B9EMAWNC0TBEW', 'T-19', '2026-10-20 07:24:16.633', '2026-10-20 07:24:16.633', NULL, 'e75ab68899d3139135d29c9cea5275a6', 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;
|
||||
|
||||
@@ -199,3 +199,96 @@ 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;
|
||||
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).
|
||||
|
||||
Live in core-api 1.16.0 (2026-10-20). Round core-api-T-16-001: 592 passed; three mutation checks each failed their test.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 22:03:37.343', '2026-10-19 22:57:59.557', NULL, '543a920de7dd1614efe0b1f936aada42', 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).
|
||||
|
||||
Live in core-api 1.16.0 (2026-10-20). Round core-api-T-16-001: 592 passed; three mutation checks each failed their test.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 22:03:37.343', '2026-10-19 22:57:59.699', NULL, 'f61bb3ef10eddef2f2d9ac925273bab5', 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 ('06GNG6WQ0V4TY8H403F0T4NWY0', 'task', NULL, 'Proposal: cap a Scribe message''s body and tags', 'Found answering claude-design (Scribe message 28): a message''s subject is capped at 500 characters, but its body, tag count and tag length are not capped at all, so nothing refuses a huge body — and every reader pays for a body in context. Proposal, awaiting the user (D-74): cap the body (64 KB?) and tags (at most 10, 63 characters each, as the Book does), refusing past it with a code the Office turns into a sentence saying where big things go (an artifact, later the file store).', 'backlog', 'low', NULL, NULL, NULL, '2026-10-20 06:59:25.574', '2026-10-20 06:59:25.574', NULL, '33d948eba82e35b1203f0d0470801362', 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 ('06GNG6WQ0V4TY8H403F0T4NWY0', 'task', NULL, 'Proposal: cap a Scribe message''s body and tags', 'Found answering claude-design (Scribe message 28): a message''s subject is capped at 500 characters, but its body, tag count and tag length are not capped at all, so nothing refuses a huge body — and every reader pays for a body in context. Proposal, awaiting the user (D-74): cap the body (64 KB?) and tags (at most 10, 63 characters each, as the Book does), refusing past it with a code the Office turns into a sentence saying where big things go (an artifact, later the file store).
|
||||
|
||||
From claude-design (Scribe message 32): if the caps are adopted, the refusal names the artifact asset route as the place big things go, until a core-api file store exists.', 'backlog', 'low', NULL, NULL, NULL, '2026-10-20 06:59:25.574', '2026-10-20 07:04:16.165', NULL, 'a4fa27f4b6cb1aa39e7c16241bba066e', 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.
|
||||
|
||||
**v1 slice live in core-api 1.17.0** (2026-10-20; round core-api-T-2-001, b4197a1 and e249df0; 633 passed; production migrated to 011 with make migrate-production). Remaining on T-2: remind, reschedule, snooze, pause/resume, undo, purge, redact, batch routes.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-20 07:24:16.383', NULL, 'bb5550d81c30fc236c856bb99d7b271f', 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 ('06GNGCJQ0KKD2RJQD1RYM0YRV4', 'task', NULL, 'The Book records when an item was closed', 'Book v1''s /book/summary counts done_today by updated_at, for want of a closing timestamp (round core-api-T-2-001): a task finished yesterday and touched today counts as done today, and the Office''s ''That''s N done today'' says so. Add closed_at, set by close and cleared by reopening, and count by it.', 'backlog', 'low', NULL, NULL, NULL, '2026-10-20 07:24:16.516', '2026-10-20 07:24:16.516', NULL, '08e47f2671091503afe8176434eefae5', 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 ('06GNGCJQF7KZ8B9EMAWNC0TBEW', 'task', NULL, 'Idempotent add holds under two simultaneous calls', 'Book v1''s idempotency key has no uniqueness constraint and no lock around the replay check (round core-api-T-2-001), so two simultaneous adds with the same new key could both create a record. A sequential retry — the case the Office''s phone clients hit — is covered. Close it with a lock on the tenant''s counter row (already taken for numbering) or a partial unique index.', 'backlog', 'low', NULL, NULL, NULL, '2026-10-20 07:24:16.633', '2026-10-20 07:24:16.633', NULL, 'c0f21b20b1fde7d78bfa0388e5f4ca4b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
|
||||
@@ -7,6 +7,83 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.17.1] - 2026-10-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`/book/summary`'s `today` counts only open items** — a task due today and closed today
|
||||
stayed in `today`, so the Office's `glance` pointed at work already finished. It now
|
||||
excludes closed statuses, as `past_due` already did. Found by the live smoke test of the
|
||||
Book v1.
|
||||
|
||||
## [1.17.0] - 2026-10-20
|
||||
|
||||
### Added
|
||||
|
||||
- **The Book v1 — the contract between core-api and the Office** (core-api T-2, this
|
||||
round's slice): `src/domains/book/`'s `{record_id}` path segment now also accepts the
|
||||
record's outward `number`, within the caller's tenant (another tenant's number answers
|
||||
`404 not_found`, exactly like a missing one); `POST …/tag`/`…/untag` of `work` or
|
||||
`personal` sets the record's scope instead of writing a free tag (D-69), and every
|
||||
response's `tags` now carries the scope alongside its free tags — `POST …/untag` of the
|
||||
current scope answers `409 scope_required`; `PATCH /book/records/{id}` accepts
|
||||
`archived`/`trashed` booleans, and `view=archive` lists archived-not-trashed records
|
||||
(trash wins, as in the Scribe); `POST …/append` adds text to a record's body, separated
|
||||
by a blank line; `POST …/checklist` ticks or unticks a checklist item by position
|
||||
(`404 checklist_item_not_found` for an unknown one); `view=due` lists open tasks due
|
||||
today or earlier, soonest first; `GET /book/records/next` answers the one next thing
|
||||
(filtered by energy budget, estimate, tag, scope; ordered by due date, pinned, priority,
|
||||
then longest untouched); `POST /book/records` takes an optional `idempotency_key` — the
|
||||
same key from the same tenant within 24 hours replays the first record (`200`,
|
||||
`"replayed": true`) rather than creating a new one (`201`, `"replayed": false`); and
|
||||
`GET /book/summary` answers one query's worth of counts (`inbox`, `today`, `past_due`,
|
||||
`pinned`, `done_today`) for the Office's `glance`. Migration `011` adds
|
||||
`book_records.idempotency_key` (nullable, no uniqueness constraint — a tenant may reuse
|
||||
a key once the 24-hour window has passed).
|
||||
- **The inbox holds unfiled notes too** (amended into the contract 2026-10-20, found by
|
||||
the Office round building against it in parallel): `view=inbox` and `/book/summary`'s
|
||||
`inbox` count now match tasks in status `inbox` **and** notes with no `parent` link — a
|
||||
Note's own status is `active`, never `inbox`, so before this a note added with only text
|
||||
never actually reached the inbox view despite D-70 saying it does. A note leaves the
|
||||
inbox once filed under a parent, archived, or trashed.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Add book_records.idempotency_key (Book v1, core-api T-2)
|
||||
|
||||
Revision ID: 011
|
||||
Revises: 010
|
||||
Create Date: 2026-10-20
|
||||
|
||||
The Book v1 contract's idempotent `add`: the same key from the same tenant
|
||||
within 24 hours answers the record it created the first time. No
|
||||
uniqueness constraint — BookService.add_record enforces the 24-hour window
|
||||
at query time, and a unique index on (owner_tenant, idempotency_key) would
|
||||
wrongly forbid a tenant reusing a key once that window has passed.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "011"
|
||||
down_revision: Union[str, None] = "010"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"book_records",
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_book_records_tenant_idempotency_key",
|
||||
"book_records",
|
||||
["owner_tenant", "idempotency_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_book_records_tenant_idempotency_key", table_name="book_records")
|
||||
op.drop_column("book_records", "idempotency_key")
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "core-api"
|
||||
version = "1.15.0"
|
||||
version = "1.17.1"
|
||||
description = "Core Code API - Infrastructure management and tools API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+140
-23
@@ -1,32 +1,43 @@
|
||||
"""
|
||||
Book Controller
|
||||
|
||||
The core Book verbs (D-70): add, show, list, find, change, close,
|
||||
link/unlink, tag/untag, history. Every route requires a caller (office or
|
||||
user); writes additionally require an identity (T-1). Every route also
|
||||
requires a tenant on the caller — enforced in the service layer
|
||||
(`BookService._require_tenant`) rather than here, so reads and writes share
|
||||
one check (core-api T-7).
|
||||
The Book verbs (D-70, Book v1 contract — core-api T-2): add, show, list,
|
||||
find, change, close, link/unlink, tag/untag, append, checklist, next,
|
||||
summary, history. Every route requires a caller (office or user); writes
|
||||
additionally require an identity (T-1). Every route also requires a tenant
|
||||
on the caller — enforced in the service layer (`BookService._require_tenant`)
|
||||
rather than here, so reads and writes share one check (core-api T-7).
|
||||
|
||||
`find` and `next` are declared before `/{record_id}` so neither literal
|
||||
segment is ever parsed as an id (Book v1 point 1 — `record_id` now also
|
||||
accepts the record's outward `number`, resolved in the service layer).
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
||||
from src.domains.auth.callers import Caller, require_caller
|
||||
from src.domains.book.models import SCOPES
|
||||
from src.domains.book.schemas import (
|
||||
BookAppendRequest,
|
||||
BookChecklistToggleRequest,
|
||||
BookCloseRequest,
|
||||
BookEventResponse,
|
||||
BookHistoryResponse,
|
||||
BookLinkRequest,
|
||||
BookLinkResponse,
|
||||
BookNextResponse,
|
||||
BookRecordAddResponse,
|
||||
BookRecordCreate,
|
||||
BookRecordDetailResponse,
|
||||
BookRecordListResponse,
|
||||
BookRecordResponse,
|
||||
BookRecordUpdate,
|
||||
BookSummaryResponse,
|
||||
BookTagRequest,
|
||||
ListView,
|
||||
RecordEnergy,
|
||||
)
|
||||
from src.domains.book.service import BookError, get_book_service
|
||||
from src.shared.base import BaseController
|
||||
@@ -40,9 +51,19 @@ require_book_reader = require_caller("office", "user")
|
||||
require_book_writer = require_caller("office", "user", identity_required=True)
|
||||
|
||||
|
||||
def _response_tags(record, tags) -> list:
|
||||
"""Every response's tags include the record's scope (Book v1 point 2 —
|
||||
`work` or `personal`, D-69's two system tags) alongside its free tags.
|
||||
`tags` here is always free-tag-only in practice (`tag_record`/
|
||||
`untag_record` special-case `work`/`personal` into `scope` rather than
|
||||
`book_tags`), but the scope names are filtered out defensively rather
|
||||
than assumed absent."""
|
||||
return [record.scope, *[t for t in tags if t not in SCOPES]]
|
||||
|
||||
|
||||
def _record_response(record, tags) -> BookRecordResponse:
|
||||
schema = BookRecordResponse.model_validate(record, from_attributes=True)
|
||||
return schema.model_copy(update={"tags": tags})
|
||||
return schema.model_copy(update={"tags": _response_tags(record, tags)})
|
||||
|
||||
|
||||
def _link_response(link) -> BookLinkResponse:
|
||||
@@ -51,7 +72,7 @@ def _link_response(link) -> BookLinkResponse:
|
||||
|
||||
def _detail_response(record, tags, links, backlinks) -> BookRecordDetailResponse:
|
||||
base = BookRecordResponse.model_validate(record, from_attributes=True).model_dump()
|
||||
base["tags"] = tags
|
||||
base["tags"] = _response_tags(record, tags)
|
||||
return BookRecordDetailResponse(
|
||||
**base,
|
||||
links=[_link_response(link) for link in links],
|
||||
@@ -79,20 +100,25 @@ class BookController(BaseController):
|
||||
|
||||
@router.post(
|
||||
"/records",
|
||||
response_model=BookRecordResponse,
|
||||
response_model=BookRecordAddResponse,
|
||||
status_code=201,
|
||||
summary="Add a record to the Book",
|
||||
)
|
||||
async def add_record(
|
||||
data: BookRecordCreate,
|
||||
response: Response,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.add_record(session, data, caller)
|
||||
record, tags, replayed = await service.add_record(session, data, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
if replayed:
|
||||
response.status_code = 200
|
||||
return BookRecordAddResponse(
|
||||
**_record_response(record, tags).model_dump(), replayed=replayed
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# list
|
||||
@@ -104,7 +130,7 @@ class BookController(BaseController):
|
||||
summary="List records",
|
||||
)
|
||||
async def list_records(
|
||||
view: Optional[ListView] = Query(None, description="inbox, today, upcoming, waiting, someday, pinned, archive, trash, done"),
|
||||
view: Optional[ListView] = Query(None, description="inbox, today, upcoming, waiting, someday, pinned, archive, trash, done, due"),
|
||||
tag: Optional[str] = Query(None),
|
||||
scope: Optional[str] = Query(None),
|
||||
parent: Optional[uuid.UUID] = Query(None),
|
||||
@@ -155,6 +181,53 @@ class BookController(BaseController):
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# next — declared before /{record_id} so "next" is never parsed as an id
|
||||
# (Book v1 point 7)
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/records/next",
|
||||
response_model=BookNextResponse,
|
||||
summary="The one next thing",
|
||||
)
|
||||
async def next_record(
|
||||
energy: Optional[RecordEnergy] = Query(None, description="at most this energy"),
|
||||
max_minutes: Optional[int] = Query(None, ge=0, description="estimate at most this"),
|
||||
tag: Optional[str] = Query(None),
|
||||
scope: Optional[str] = Query(None),
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.next_record(
|
||||
session, caller, energy=energy, max_minutes=max_minutes, tag=tag, scope=scope,
|
||||
)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
if record is None:
|
||||
return BookNextResponse(record=None)
|
||||
return BookNextResponse(record=_record_response(record, tags))
|
||||
|
||||
# =====================================================================
|
||||
# summary (Book v1 point 9)
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/summary",
|
||||
response_model=BookSummaryResponse,
|
||||
summary="Counts for the caller's tenant, excluding archived and trashed",
|
||||
)
|
||||
async def summary(
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
counts = await service.get_summary(session, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return BookSummaryResponse(**counts)
|
||||
|
||||
# =====================================================================
|
||||
# show
|
||||
# =====================================================================
|
||||
@@ -165,7 +238,7 @@ class BookController(BaseController):
|
||||
summary="Show a record, its links and its tags",
|
||||
)
|
||||
async def show_record(
|
||||
record_id: uuid.UUID,
|
||||
record_id: str,
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
@@ -185,7 +258,7 @@ class BookController(BaseController):
|
||||
summary="Change a record",
|
||||
)
|
||||
async def change_record(
|
||||
record_id: uuid.UUID,
|
||||
record_id: str,
|
||||
data: BookRecordUpdate,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
@@ -206,7 +279,7 @@ class BookController(BaseController):
|
||||
summary="Close a record (task done/dropped, question resolved, decision superseded)",
|
||||
)
|
||||
async def close_record(
|
||||
record_id: uuid.UUID,
|
||||
record_id: str,
|
||||
data: BookCloseRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
@@ -227,7 +300,7 @@ class BookController(BaseController):
|
||||
summary="Link a record to another",
|
||||
)
|
||||
async def link_record(
|
||||
record_id: uuid.UUID,
|
||||
record_id: str,
|
||||
data: BookLinkRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
@@ -246,7 +319,7 @@ class BookController(BaseController):
|
||||
summary="Remove a link between two records",
|
||||
)
|
||||
async def unlink_record(
|
||||
record_id: uuid.UUID,
|
||||
record_id: str,
|
||||
data: BookLinkRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
@@ -269,7 +342,7 @@ class BookController(BaseController):
|
||||
summary="Tag a record",
|
||||
)
|
||||
async def tag_record(
|
||||
record_id: uuid.UUID,
|
||||
record_id: str,
|
||||
data: BookTagRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
@@ -286,7 +359,7 @@ class BookController(BaseController):
|
||||
summary="Remove a tag from a record",
|
||||
)
|
||||
async def untag_record(
|
||||
record_id: uuid.UUID,
|
||||
record_id: str,
|
||||
data: BookTagRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
@@ -307,19 +380,63 @@ class BookController(BaseController):
|
||||
summary="The append-only event history for a record",
|
||||
)
|
||||
async def record_history(
|
||||
record_id: uuid.UUID,
|
||||
record_id: str,
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
events = await service.get_history(session, record_id, caller)
|
||||
resolved_id, events = await service.get_history(session, record_id, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return BookHistoryResponse(
|
||||
record_id=record_id,
|
||||
record_id=resolved_id,
|
||||
events=[_event_response(event) for event in events],
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# append (Book v1 point 4)
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/append",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Append text to the end of a record's body",
|
||||
)
|
||||
async def append_record(
|
||||
record_id: str,
|
||||
data: BookAppendRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.append_text(session, record_id, data.text, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# checklist (Book v1 point 5)
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/checklist",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Tick or untick a checklist item",
|
||||
)
|
||||
async def checklist_toggle(
|
||||
record_id: str,
|
||||
data: BookChecklistToggleRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.toggle_checklist_item(
|
||||
session, record_id, data.position, data.checked, caller
|
||||
)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ DEFAULT_STATUS_BY_KIND = {
|
||||
"rejected": "rejected",
|
||||
}
|
||||
|
||||
# A record is "closed" — no longer open work — once its status lands here,
|
||||
# whatever its kind. Used by BookService.get_summary's `past_due` bucket,
|
||||
# which (like `today`/`past_due` themselves) is not kind-restricted, so it
|
||||
# needs a status set that means "done" across every kind rather than just
|
||||
# a task's. "note" has no closed state at all (its only status is
|
||||
# "active"), so it never contributes to this set and never matches here.
|
||||
TERMINAL_STATUSES = {"done", "dropped", "settled", "superseded", "rejected"}
|
||||
|
||||
SCOPES = ("work", "personal")
|
||||
DEFAULT_SCOPE = "personal"
|
||||
|
||||
@@ -65,6 +73,7 @@ class BookRecord(Base):
|
||||
UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
||||
UniqueConstraint("owner_tenant", "number", name="uq_book_records_tenant_number"),
|
||||
Index("ix_book_records_tenant_kind_status", "owner_tenant", "kind", "status"),
|
||||
Index("ix_book_records_tenant_idempotency_key", "owner_tenant", "idempotency_key"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
@@ -115,6 +124,13 @@ class BookRecord(Base):
|
||||
source: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
external_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
colour: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
# The Book v1 contract's idempotent `add` (migration 011, core-api
|
||||
# T-2). No uniqueness constraint: the same key is a replay only within
|
||||
# 24 hours of the first row that carries it (BookService.add_record),
|
||||
# and a tenant is free to reuse a key once that window has passed —
|
||||
# a unique index on (owner_tenant, idempotency_key) would wrongly
|
||||
# forbid that second, legitimate row.
|
||||
idempotency_key: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
Book Domain Schemas
|
||||
|
||||
Pydantic request/response payloads for the Book (D-68, D-70). This round
|
||||
ships `add`, `show`, `list`, `find`, `change`, `close`, `link`/`unlink`,
|
||||
`tag`/`untag`, `history` — the verbs the Office needs next round are not
|
||||
schematized here (dossier: append, remind, reschedule, undo, purge, redact,
|
||||
pin, check, snooze, archive/trash/restore, pause/resume, due, next, batch,
|
||||
idempotency keys).
|
||||
(the Book v1 contract, core-api T-2) adds the record-by-number lookup,
|
||||
work/personal as tags, archive/trash, `append`, `checklist`, `view=due`,
|
||||
`next`, an idempotent `add`, and `/book/summary` on top of the previous
|
||||
round's `add`, `show`, `list`, `find`, `change`, `close`, `link`/`unlink`,
|
||||
`tag`/`untag`, `history`. Still not schematized here: `remind`,
|
||||
`reschedule`, `snooze`, `pause`/`resume`, `undo`, `purge`, `redact`, batch
|
||||
routes, attachments — later slices of T-2.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -23,7 +25,7 @@ RecurrenceMode = Literal["schedule", "after_completion"]
|
||||
LinkRelation = Literal["parent", "blocks", "resolves", "supersedes", "related"]
|
||||
ListView = Literal[
|
||||
"inbox", "today", "upcoming", "waiting", "someday",
|
||||
"pinned", "archive", "trash", "done",
|
||||
"pinned", "archive", "trash", "done", "due",
|
||||
]
|
||||
|
||||
|
||||
@@ -63,10 +65,18 @@ class BookRecordCreate(BaseSchema):
|
||||
parent: Optional[uuid.UUID] = Field(
|
||||
None, description="Convenience: also create a `parent` link to this record"
|
||||
)
|
||||
idempotency_key: Optional[str] = Field(
|
||||
None, max_length=255,
|
||||
description="The same key from this tenant within 24 hours replays the first record",
|
||||
)
|
||||
|
||||
|
||||
class BookRecordUpdate(BaseSchema):
|
||||
"""`change` — every field optional; only what is set is applied."""
|
||||
"""`change` — every field optional; only what is set is applied.
|
||||
`archived`/`trashed` are the Book v1 contract's point 3: true sets
|
||||
`archived_at`/`trashed_at` to now, false clears it — they are not real
|
||||
columns, so BookService.change_record translates them before its
|
||||
generic per-field diff loop runs."""
|
||||
|
||||
kind: Optional[RecordKind] = None
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
@@ -89,6 +99,8 @@ class BookRecordUpdate(BaseSchema):
|
||||
source: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
colour: Optional[str] = None
|
||||
archived: Optional[bool] = None
|
||||
trashed: Optional[bool] = None
|
||||
|
||||
|
||||
class BookCloseRequest(BaseSchema):
|
||||
@@ -112,11 +124,26 @@ class BookLinkRequest(BaseSchema):
|
||||
|
||||
|
||||
class BookTagRequest(BaseSchema):
|
||||
"""`tag` / `untag`."""
|
||||
"""`tag` / `untag`. `tag` being `work` or `personal` (D-69) sets the
|
||||
record's scope instead of writing a `book_tags` row — `untag` of the
|
||||
record's current scope answers `409 scope_required` (Book v1 point 2)."""
|
||||
|
||||
tag: str = Field(..., min_length=1, max_length=63)
|
||||
|
||||
|
||||
class BookAppendRequest(BaseSchema):
|
||||
"""`append` — Book v1 point 4."""
|
||||
|
||||
text: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class BookChecklistToggleRequest(BaseSchema):
|
||||
"""`checklist` — Book v1 point 5: tick or untick the item at `position`."""
|
||||
|
||||
position: int
|
||||
checked: bool
|
||||
|
||||
|
||||
class BookLinkResponse(BaseSchema):
|
||||
from_id: uuid.UUID
|
||||
relation: str
|
||||
@@ -172,6 +199,32 @@ class BookRecordDetailResponse(BookRecordResponse):
|
||||
backlinks: List[BookLinkResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookRecordAddResponse(BookRecordResponse):
|
||||
"""`add`'s own response (Book v1 point 8): whether this call replayed
|
||||
an existing record (same idempotency key, same tenant, within 24
|
||||
hours — status `200`) or created a new one (status `201`)."""
|
||||
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
class BookNextResponse(BaseSchema):
|
||||
"""`GET /book/records/next` (Book v1 point 7) — the one next thing, or
|
||||
none."""
|
||||
|
||||
record: Optional[BookRecordResponse] = None
|
||||
|
||||
|
||||
class BookSummaryResponse(BaseSchema):
|
||||
"""`GET /book/summary` (Book v1 point 9) — one query's worth of counts
|
||||
for the caller's tenant, excluding archived and trashed records."""
|
||||
|
||||
inbox: int
|
||||
today: int
|
||||
past_due: int
|
||||
pinned: int
|
||||
done_today: int
|
||||
|
||||
|
||||
class BookRecordListResponse(BaseSchema):
|
||||
records: List[BookRecordResponse]
|
||||
total: int
|
||||
|
||||
+400
-40
@@ -19,18 +19,21 @@ tenant-safe in the first place.
|
||||
Methods take `session: AsyncSession` per call rather than storing it on
|
||||
`self`, following `src/domains/dashboard/service.py`.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import and_, delete, func, nulls_last, or_, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.callers import Caller
|
||||
from src.domains.book.models import (
|
||||
DEFAULT_STATUS_BY_KIND,
|
||||
SCOPES,
|
||||
STATUSES_BY_KIND,
|
||||
TERMINAL_STATUSES,
|
||||
BookEvent,
|
||||
BookRecord,
|
||||
BookRecordLink,
|
||||
@@ -88,6 +91,29 @@ class BookService:
|
||||
raise BookError("not_found", 404)
|
||||
return record
|
||||
|
||||
async def _resolve_path_record(self, session: AsyncSession, ref: str, tenant: str) -> BookRecord:
|
||||
"""Every `/book/records/{record_id}` route also accepts the
|
||||
record's outward `number` (Book v1 point 1): a path segment that is
|
||||
all digits is a `number` lookup, scoped to `tenant` exactly like
|
||||
`_get_or_404`; anything else is parsed as the `id` UUID. Another
|
||||
tenant's number, an unparseable id, and a missing record all answer
|
||||
the same `404 not_found` — none of them exist from this caller's
|
||||
side of the boundary (core-api T-7 / workspace D-73)."""
|
||||
if ref.isdigit():
|
||||
stmt = select(BookRecord).where(
|
||||
BookRecord.owner_tenant == tenant, BookRecord.number == int(ref)
|
||||
)
|
||||
record = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise BookError("not_found", 404)
|
||||
return record
|
||||
|
||||
try:
|
||||
record_uuid = uuid.UUID(ref)
|
||||
except ValueError as error:
|
||||
raise BookError("not_found", 404) from error
|
||||
return await self._get_or_404(session, record_uuid, tenant)
|
||||
|
||||
async def _allocate_number(self, session: AsyncSession, tenant: str) -> int:
|
||||
"""
|
||||
Allocate the next outward `number` for `tenant`, safe under
|
||||
@@ -147,6 +173,23 @@ class BookService:
|
||||
session.add(event)
|
||||
await session.flush()
|
||||
|
||||
def _inbox_condition(self):
|
||||
"""Book v1 point 10 (amended 2026-10-20): the inbox is status
|
||||
`inbox` (tasks) plus any unfiled Note — one with no outgoing
|
||||
`parent` link — so `add`'s "text-only input lands an unfiled Note
|
||||
in the inbox" (D-72) is literally true; a Note files out of the
|
||||
inbox the moment it gets a parent. Shared by `list_records`'s
|
||||
`view=inbox` and `get_summary`'s `inbox` count so the two can never
|
||||
drift apart the way they did before this point existed — that drift
|
||||
is exactly what the Office round caught."""
|
||||
unfiled_note = and_(
|
||||
BookRecord.kind == "note",
|
||||
~BookRecord.id.in_(
|
||||
select(BookRecordLink.from_id).where(BookRecordLink.relation == "parent")
|
||||
),
|
||||
)
|
||||
return or_(BookRecord.status == "inbox", unfiled_note)
|
||||
|
||||
def _validate_status(self, kind: str, status: Optional[str]) -> str:
|
||||
"""Resolve and validate a status for `kind`; defaults when unset."""
|
||||
resolved = status or DEFAULT_STATUS_BY_KIND[kind]
|
||||
@@ -166,10 +209,42 @@ class BookService:
|
||||
session: AsyncSession,
|
||||
data: BookRecordCreate,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
) -> Tuple[BookRecord, List[str], bool]:
|
||||
"""`add` — text-only input lands an unfiled Note (D-72). Sets
|
||||
`owner_tenant` from the caller and allocates `number` (core-api T-7)."""
|
||||
`owner_tenant` from the caller and allocates `number` (core-api T-7).
|
||||
|
||||
Book v1 point 8: an `idempotency_key` that this tenant has already
|
||||
used within the last 24 hours short-circuits to the record it
|
||||
created the first time (`replayed=True`) rather than allocating a
|
||||
new number or writing a new `add` event — a true replay changes
|
||||
nothing. No locking here (unlike `_allocate_number`): two
|
||||
concurrent `add`s carrying the same brand-new key could each miss
|
||||
the other's not-yet-committed row and both create a record: this
|
||||
round's verification only covers the single-caller replay the
|
||||
contract describes, so that race is accepted rather than guarded.
|
||||
"""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
|
||||
if data.idempotency_key:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
replay_stmt = (
|
||||
select(BookRecord)
|
||||
.where(
|
||||
BookRecord.owner_tenant == tenant,
|
||||
BookRecord.idempotency_key == data.idempotency_key,
|
||||
BookRecord.created_at >= cutoff,
|
||||
)
|
||||
.order_by(BookRecord.created_at.desc())
|
||||
)
|
||||
existing = (await session.execute(replay_stmt)).scalars().first()
|
||||
if existing is not None:
|
||||
tags = await self._tags_for(session, existing.id)
|
||||
logger.info(
|
||||
f"Book add replayed: id={existing.id} number={existing.number} "
|
||||
f"tenant={tenant} key={data.idempotency_key!r}"
|
||||
)
|
||||
return existing, tags, True
|
||||
|
||||
status = self._validate_status(data.kind, data.status)
|
||||
number = await self._allocate_number(session, tenant)
|
||||
|
||||
@@ -197,6 +272,7 @@ class BookService:
|
||||
source=data.source,
|
||||
external_id=data.external_id,
|
||||
colour=data.colour,
|
||||
idempotency_key=data.idempotency_key,
|
||||
created_by=caller.identity,
|
||||
)
|
||||
session.add(record)
|
||||
@@ -224,23 +300,25 @@ class BookService:
|
||||
await session.refresh(record)
|
||||
|
||||
logger.info(f"Book record added: kind={data.kind} id={record.id} number={number} tenant={tenant} by={caller.identity}")
|
||||
return record, tag_names
|
||||
return record, tag_names, False
|
||||
|
||||
async def show_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
record_ref: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str], List[BookRecordLink], List[BookRecordLink]]:
|
||||
"""`show` — the record, its tags, outgoing links and backlinks."""
|
||||
"""`show` — the record, its tags, outgoing links and backlinks.
|
||||
`record_ref` is the path segment as written — a number or a UUID
|
||||
(Book v1 point 1), resolved by `_resolve_path_record`."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
tags = await self._tags_for(session, record_id)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
tags = await self._tags_for(session, record.id)
|
||||
|
||||
links_stmt = select(BookRecordLink).where(BookRecordLink.from_id == record_id)
|
||||
links_stmt = select(BookRecordLink).where(BookRecordLink.from_id == record.id)
|
||||
links = list((await session.execute(links_stmt)).scalars().all())
|
||||
|
||||
backlinks_stmt = select(BookRecordLink).where(BookRecordLink.to_id == record_id)
|
||||
backlinks_stmt = select(BookRecordLink).where(BookRecordLink.to_id == record.id)
|
||||
backlinks = list((await session.execute(backlinks_stmt)).scalars().all())
|
||||
|
||||
return record, tags, links, backlinks
|
||||
@@ -258,23 +336,34 @@ class BookService:
|
||||
offset: int = 0,
|
||||
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
|
||||
"""
|
||||
`list` — the nine views of D-70, scoped to the caller's tenant.
|
||||
Status-named views (`inbox`, `waiting`, `someday`, `done`) filter on
|
||||
the matching task status and simply return nothing for kinds that
|
||||
never carry it. `today` and `upcoming` bucket on `due_at` in
|
||||
`book_timezone`; richer due/overdue handling is `due`/`next`, both
|
||||
next round. `number=` finds the one record with that outward id in
|
||||
the caller's tenant (core-api T-7).
|
||||
`list` — the ten views of D-70 plus Book v1 point 6, scoped to the
|
||||
caller's tenant. `inbox` is status `inbox` **or** an unfiled Note
|
||||
(point 10 — `self._inbox_condition()`); `waiting`/`someday`/`done`
|
||||
filter on the matching task status and simply return nothing for
|
||||
kinds that never carry it. `today` and `upcoming` bucket on
|
||||
`due_at` in `book_timezone`; `due` (point 6) is `today`'s open-task,
|
||||
trash-wins sibling — the one next thing is `next`, a separate
|
||||
route. `number=` finds the one record with that outward id in the
|
||||
caller's tenant (core-api T-7).
|
||||
|
||||
`archive` lists archived records that are **not** trashed — trash
|
||||
wins, as in the Scribe (point 3) — so it carries its own explicit
|
||||
`trashed_at IS NULL` alongside `trash`'s own `trashed_at IS NOT
|
||||
NULL`; every other view excludes both archived and trashed.
|
||||
"""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
conditions = [BookRecord.owner_tenant == tenant]
|
||||
order_by = BookRecord.last_touched_at.desc()
|
||||
|
||||
if view in ("inbox", "waiting", "someday", "done"):
|
||||
if view == "inbox":
|
||||
conditions.append(self._inbox_condition())
|
||||
elif view in ("waiting", "someday", "done"):
|
||||
conditions.append(BookRecord.status == view)
|
||||
elif view == "pinned":
|
||||
conditions.append(BookRecord.pinned.is_(True))
|
||||
elif view == "archive":
|
||||
conditions.append(BookRecord.archived_at.isnot(None))
|
||||
conditions.append(BookRecord.trashed_at.is_(None))
|
||||
elif view == "trash":
|
||||
conditions.append(BookRecord.trashed_at.isnot(None))
|
||||
elif view in ("today", "upcoming"):
|
||||
@@ -288,6 +377,15 @@ class BookService:
|
||||
conditions.append(BookRecord.due_at <= day_end)
|
||||
else:
|
||||
conditions.append(BookRecord.due_at > day_end)
|
||||
elif view == "due":
|
||||
tz = ZoneInfo(get_settings().book_timezone)
|
||||
local_now = datetime.now(tz)
|
||||
day_end = local_now.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
conditions.append(BookRecord.kind == "task")
|
||||
conditions.append(BookRecord.status.in_(("inbox", "open")))
|
||||
conditions.append(BookRecord.due_at.isnot(None))
|
||||
conditions.append(BookRecord.due_at <= day_end)
|
||||
order_by = BookRecord.due_at.asc()
|
||||
|
||||
if view not in ("archive", "trash"):
|
||||
conditions.append(BookRecord.archived_at.is_(None))
|
||||
@@ -317,7 +415,7 @@ class BookService:
|
||||
|
||||
query = (
|
||||
base_query
|
||||
.order_by(BookRecord.last_touched_at.desc())
|
||||
.order_by(order_by)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
@@ -366,17 +464,39 @@ class BookService:
|
||||
async def change_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
record_ref: str,
|
||||
data: BookRecordUpdate,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`change` — a partial update; only fields set on the request move."""
|
||||
"""`change` — a partial update; only fields set on the request move.
|
||||
`record_ref` is the path segment as written — a number or a UUID
|
||||
(Book v1 point 1). `archived`/`trashed` (point 3) are translated to
|
||||
`archived_at`/`trashed_at` before the generic per-field diff loop,
|
||||
since they are not real columns on `BookRecord` and `setattr` would
|
||||
otherwise just create a stray instance attribute no one persists."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
record_id = record.id
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
diff: dict = {}
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
if "archived" in update_data:
|
||||
flag = update_data.pop("archived")
|
||||
new_value = now if flag else None
|
||||
before = record.archived_at
|
||||
if before != new_value:
|
||||
diff["archived_at"] = {"from": str(before), "to": str(new_value)}
|
||||
record.archived_at = new_value
|
||||
if "trashed" in update_data:
|
||||
flag = update_data.pop("trashed")
|
||||
new_value = now if flag else None
|
||||
before = record.trashed_at
|
||||
if before != new_value:
|
||||
diff["trashed_at"] = {"from": str(before), "to": str(new_value)}
|
||||
record.trashed_at = new_value
|
||||
|
||||
new_kind = update_data.get("kind", record.kind)
|
||||
if "status" in update_data or "kind" in update_data:
|
||||
resolved_status = self._validate_status(new_kind, update_data.get("status"))
|
||||
@@ -409,7 +529,7 @@ class BookService:
|
||||
async def close_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
record_ref: str,
|
||||
data: BookCloseRequest,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
@@ -419,10 +539,12 @@ class BookService:
|
||||
superseded by a newer one (`supersedes` link, status `superseded`).
|
||||
`resolved_by`/`superseded_by` are link targets, so each is looked up
|
||||
in the caller's tenant the same as `link` — a target in another
|
||||
tenant answers 404 not_found.
|
||||
tenant answers 404 not_found. `record_ref` is the path segment as
|
||||
written — a number or a UUID (Book v1 point 1).
|
||||
"""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
record_id = record.id
|
||||
diff: dict = {}
|
||||
|
||||
if record.kind == "task":
|
||||
@@ -481,15 +603,19 @@ class BookService:
|
||||
async def link_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
record_ref: str,
|
||||
to_id,
|
||||
relation: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`link` refuses a target in another tenant the same way a missing
|
||||
one is refused — both are 404 not_found (core-api T-7)."""
|
||||
one is refused — both are 404 not_found (core-api T-7). `record_ref`
|
||||
is the path segment as written — a number or a UUID (Book v1 point
|
||||
1); `to_id` stays a UUID-only link target (unchanged by point 1,
|
||||
which only names the `{record_id}` path segment)."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
record_id = record.id
|
||||
await self._get_or_404(session, to_id, tenant) # the target must exist in the same tenant
|
||||
|
||||
session.add(BookRecordLink(
|
||||
@@ -510,13 +636,14 @@ class BookService:
|
||||
async def unlink_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
record_ref: str,
|
||||
to_id,
|
||||
relation: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
record_id = record.id
|
||||
|
||||
stmt = delete(BookRecordLink).where(
|
||||
BookRecordLink.from_id == record_id,
|
||||
@@ -543,12 +670,28 @@ class BookService:
|
||||
async def tag_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
record_ref: str,
|
||||
tag: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`tag` — `record_ref` is the path segment as written, a number or
|
||||
a UUID (Book v1 point 1). `tag` being `work` or `personal` (D-69,
|
||||
point 2) sets `scope` instead of writing a `book_tags` row — it is
|
||||
idempotent the same way a free tag is: setting the scope it already
|
||||
has writes no new event."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
record_id = record.id
|
||||
|
||||
if tag in SCOPES:
|
||||
if record.scope != tag:
|
||||
record.scope = tag
|
||||
self._touch(record)
|
||||
await self._write_event(session, record_id, caller, "tag", {"scope": tag})
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
existing = await session.get(BookTag, (record_id, tag))
|
||||
if existing is None:
|
||||
@@ -562,12 +705,24 @@ class BookService:
|
||||
async def untag_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
record_ref: str,
|
||||
tag: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`untag` — removing the record's **current** scope answers `409
|
||||
scope_required` (point 2): every item carries exactly one, so the
|
||||
fix is to tag the other, not to leave it with none. Naming the
|
||||
scope it does *not* currently hold is a no-op, the same as
|
||||
untagging a free tag that was never set."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
record_id = record.id
|
||||
|
||||
if tag in SCOPES:
|
||||
if record.scope == tag:
|
||||
raise BookError("scope_required", 409)
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
stmt = delete(BookTag).where(BookTag.record_id == record_id, BookTag.tag == tag)
|
||||
result = await session.execute(stmt)
|
||||
@@ -578,23 +733,228 @@ class BookService:
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
# =========================================================================
|
||||
# append / checklist (Book v1 points 4, 5)
|
||||
# =========================================================================
|
||||
|
||||
async def append_text(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_ref: str,
|
||||
text: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`append` (point 4) — adds `text` to the end of the body,
|
||||
separated from what was there by a blank line; an empty body is
|
||||
just replaced, since there is nothing to separate from."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
record_id = record.id
|
||||
|
||||
record.body = f"{record.body}\n\n{text}" if record.body else text
|
||||
self._touch(record)
|
||||
await self._write_event(session, record_id, caller, "append", {"text": text})
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
async def toggle_checklist_item(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_ref: str,
|
||||
position: int,
|
||||
checked: bool,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`checklist` (point 5) — ticks or unticks the item at `position`;
|
||||
an unknown position is `404 checklist_item_not_found`. Reassigns
|
||||
`record.checklist` to a new list object (rather than mutating the
|
||||
existing one in place) so SQLAlchemy's change tracking actually
|
||||
sees the write — a plain in-place `.append`/key-set on a JSONB
|
||||
column's Python list is invisible to it without `MutableList`."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
record_id = record.id
|
||||
|
||||
checklist = [dict(item) for item in (record.checklist or [])]
|
||||
matched = next((item for item in checklist if item.get("position") == position), None)
|
||||
if matched is None:
|
||||
raise BookError("checklist_item_not_found", 404)
|
||||
matched["checked"] = checked
|
||||
record.checklist = checklist
|
||||
|
||||
self._touch(record)
|
||||
await self._write_event(
|
||||
session, record_id, caller, "checklist", {"position": position, "checked": checked}
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
# =========================================================================
|
||||
# next (Book v1 point 7)
|
||||
# =========================================================================
|
||||
|
||||
async def next_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
caller: Caller,
|
||||
energy: Optional[str] = None,
|
||||
max_minutes: Optional[int] = None,
|
||||
tag: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
) -> Tuple[Optional[BookRecord], List[str]]:
|
||||
"""`GET /book/records/next` — the one next thing, or none.
|
||||
|
||||
Candidates: open tasks (status `inbox` or `open`), not archived or
|
||||
trashed, whose `start_at` is unset or past. `energy` is a budget —
|
||||
"at most this" — so a candidate with no `energy` set is never
|
||||
excluded by it (there is nothing to compare); `max_minutes` treats
|
||||
`estimate_minutes` the same way. Both are judgement calls: the
|
||||
contract names the filter but not what an unset field does against
|
||||
it.
|
||||
|
||||
Order: due soonest first (undated last via `nulls_last`), then
|
||||
pinned, then higher priority (undated last), then the longest
|
||||
untouched — so what has been waiting resurfaces.
|
||||
"""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
conditions = [
|
||||
BookRecord.owner_tenant == tenant,
|
||||
BookRecord.kind == "task",
|
||||
BookRecord.status.in_(("inbox", "open")),
|
||||
BookRecord.archived_at.is_(None),
|
||||
BookRecord.trashed_at.is_(None),
|
||||
or_(BookRecord.start_at.is_(None), BookRecord.start_at <= datetime.now(timezone.utc)),
|
||||
]
|
||||
|
||||
if energy is not None:
|
||||
order_map = {"low": 0, "medium": 1, "high": 2}
|
||||
allowed = [e for e, rank in order_map.items() if rank <= order_map[energy]]
|
||||
conditions.append(or_(BookRecord.energy.is_(None), BookRecord.energy.in_(allowed)))
|
||||
|
||||
if max_minutes is not None:
|
||||
conditions.append(
|
||||
or_(BookRecord.estimate_minutes.is_(None), BookRecord.estimate_minutes <= max_minutes)
|
||||
)
|
||||
|
||||
if scope is not None:
|
||||
conditions.append(BookRecord.scope == scope)
|
||||
|
||||
if tag is not None:
|
||||
tag_subquery = select(BookTag.record_id).where(BookTag.tag == tag)
|
||||
conditions.append(BookRecord.id.in_(tag_subquery))
|
||||
|
||||
query = (
|
||||
select(BookRecord)
|
||||
.where(*conditions)
|
||||
.order_by(
|
||||
nulls_last(BookRecord.due_at.asc()),
|
||||
BookRecord.pinned.desc(),
|
||||
nulls_last(BookRecord.priority.desc()),
|
||||
BookRecord.last_touched_at.asc(),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
record = (await session.execute(query)).scalars().first()
|
||||
if record is None:
|
||||
return None, []
|
||||
|
||||
tags = await self._tags_for(session, record.id)
|
||||
return record, tags
|
||||
|
||||
# =========================================================================
|
||||
# summary (Book v1 point 9)
|
||||
# =========================================================================
|
||||
|
||||
async def get_summary(self, session: AsyncSession, caller: Caller) -> dict:
|
||||
"""`GET /book/summary` — one query's worth of counts for the
|
||||
caller's tenant, excluding archived and trashed. `inbox` is the
|
||||
same set `list_records(view="inbox")` answers (point 10 —
|
||||
`self._inbox_condition()`). `past_due`/`today` are not kind-
|
||||
restricted, matching `list_records`'s own `today`/`upcoming`
|
||||
views; `past_due`'s "still open" is any non-terminal status across
|
||||
every kind (`TERMINAL_STATUSES`), since due dates are not
|
||||
task-only in the schema even though they mostly are in practice —
|
||||
a judgement call. `done_today` uses `updated_at` as a stand-in for
|
||||
a closing timestamp the schema does not carry (no `closed_at`
|
||||
column exists); a record touched again later without leaving
|
||||
`done` would stop counting, and one touched again while still
|
||||
`done` keeps counting on its later touch, not its actual close —
|
||||
an accepted approximation, not a precise close-event count.
|
||||
"""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
tz = ZoneInfo(get_settings().book_timezone)
|
||||
local_now = datetime.now(tz)
|
||||
day_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
base = [
|
||||
BookRecord.owner_tenant == tenant,
|
||||
BookRecord.archived_at.is_(None),
|
||||
BookRecord.trashed_at.is_(None),
|
||||
]
|
||||
|
||||
async def _count(*extra) -> int:
|
||||
stmt = select(func.count()).select_from(BookRecord).where(*base, *extra)
|
||||
return (await session.execute(stmt)).scalar() or 0
|
||||
|
||||
inbox = await _count(self._inbox_condition())
|
||||
# Still open, like `past_due`: a task closed today is done, not "due
|
||||
# today" — the Office's glance would otherwise point at work already
|
||||
# finished.
|
||||
today = await _count(
|
||||
BookRecord.due_at.isnot(None),
|
||||
BookRecord.due_at >= day_start,
|
||||
BookRecord.due_at <= day_end,
|
||||
BookRecord.status.notin_(TERMINAL_STATUSES),
|
||||
)
|
||||
past_due = await _count(
|
||||
BookRecord.due_at.isnot(None),
|
||||
BookRecord.due_at < day_start,
|
||||
BookRecord.status.notin_(TERMINAL_STATUSES),
|
||||
)
|
||||
pinned = await _count(BookRecord.pinned.is_(True))
|
||||
done_today = await _count(
|
||||
BookRecord.status == "done",
|
||||
BookRecord.updated_at >= day_start,
|
||||
BookRecord.updated_at <= day_end,
|
||||
)
|
||||
|
||||
return {
|
||||
"inbox": inbox,
|
||||
"today": today,
|
||||
"past_due": past_due,
|
||||
"pinned": pinned,
|
||||
"done_today": done_today,
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# history
|
||||
# =========================================================================
|
||||
|
||||
async def get_history(
|
||||
self, session: AsyncSession, record_id, caller: Caller, limit: int = 100
|
||||
) -> List[BookEvent]:
|
||||
self, session: AsyncSession, record_ref: str, caller: Caller, limit: int = 100
|
||||
) -> Tuple[uuid.UUID, List[BookEvent]]:
|
||||
"""`record_ref` is the path segment as written — a number or a
|
||||
UUID (Book v1 point 1). Returns the record's actual `id` alongside
|
||||
its events, since `record_ref` itself may be a `number` rather
|
||||
than the id the response must carry."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
await self._get_or_404(session, record_id, tenant)
|
||||
record = await self._resolve_path_record(session, record_ref, tenant)
|
||||
|
||||
stmt = (
|
||||
select(BookEvent)
|
||||
.where(BookEvent.record_id == record_id)
|
||||
.where(BookEvent.record_id == record.id)
|
||||
.order_by(BookEvent.at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
events = list((await session.execute(stmt)).scalars().all())
|
||||
return record.id, events
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
+547
-5
@@ -8,6 +8,7 @@ to production regardless of FastAPI's dependency resolution order.
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -248,7 +249,9 @@ class TestAddShowListFind:
|
||||
).json()
|
||||
|
||||
child_shown = client.get(f"/book/records/{child['id']}", headers=office_headers()).json()
|
||||
assert child_shown["tags"] == ["chore"]
|
||||
# Book v1 point 2: every response's `tags` includes the record's
|
||||
# scope (default `personal`) alongside its free tags.
|
||||
assert child_shown["tags"] == ["personal", "chore"]
|
||||
assert len(child_shown["links"]) == 1
|
||||
assert child_shown["links"][0]["relation"] == "parent"
|
||||
assert child_shown["links"][0]["to_id"] == parent["id"]
|
||||
@@ -430,12 +433,13 @@ class TestTagUntag:
|
||||
tagged = client.post(
|
||||
f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers()
|
||||
).json()
|
||||
assert tagged["tags"] == ["chore"]
|
||||
# Book v1 point 2: the response's tags include the scope too.
|
||||
assert tagged["tags"] == ["personal", "chore"]
|
||||
|
||||
untagged = client.post(
|
||||
f"/book/records/{record['id']}/untag", json={"tag": "chore"}, headers=office_headers()
|
||||
).json()
|
||||
assert untagged["tags"] == []
|
||||
assert untagged["tags"] == ["personal"]
|
||||
|
||||
def test_tagging_twice_is_idempotent(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
@@ -444,7 +448,7 @@ class TestTagUntag:
|
||||
f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers()
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["tags"] == ["chore"]
|
||||
assert second.json()["tags"] == ["personal", "chore"]
|
||||
|
||||
|
||||
class TestHistory:
|
||||
@@ -548,7 +552,7 @@ class TestNumbering:
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
record, _ = await service.add_record(session, BookRecordCreate(body=body), caller)
|
||||
record, _, _ = await service.add_record(session, BookRecordCreate(body=body), caller)
|
||||
return record.number
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -755,3 +759,541 @@ class TestTenantRegistryEnforcement:
|
||||
still reaches the route."""
|
||||
response = client.get("/book/records", headers=office_headers())
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestNumberLookup:
|
||||
"""Book v1 point 1: every `/book/records/{record_id}` route also
|
||||
accepts the record's outward `number`, within the caller's tenant."""
|
||||
|
||||
def test_show_by_number(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
response = client.get(f"/book/records/{record['number']}", headers=office_headers())
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == record["id"]
|
||||
|
||||
def test_change_by_number(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "x"}, headers=office_headers()
|
||||
).json()
|
||||
response = client.patch(
|
||||
f"/book/records/{record['number']}", json={"priority": 3}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["priority"] == 3
|
||||
|
||||
def test_close_by_number(self, client):
|
||||
task = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "x"}, headers=office_headers()
|
||||
).json()
|
||||
response = client.post(
|
||||
f"/book/records/{task['number']}/close", json={"outcome": "done"}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "done"
|
||||
|
||||
def test_tag_by_number(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['number']}/tag", json={"tag": "chore"}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "chore" in response.json()["tags"]
|
||||
|
||||
def test_link_by_number(self, client):
|
||||
a = client.post("/book/records", json={"body": "a"}, headers=office_headers()).json()
|
||||
b = client.post("/book/records", json={"body": "b"}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{a['number']}/link",
|
||||
json={"to_id": b["id"], "relation": "related"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_append_by_number(self, client):
|
||||
record = client.post("/book/records", json={"body": "first"}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['number']}/append", json={"text": "second"}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["body"] == "first\n\nsecond"
|
||||
|
||||
def test_checklist_by_number(self, client):
|
||||
record = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "x", "checklist": [{"text": "step", "position": 0}]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['number']}/checklist",
|
||||
json={"position": 0, "checked": True},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["checklist"][0]["checked"] is True
|
||||
|
||||
def test_history_by_number(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
response = client.get(f"/book/records/{record['number']}/history", headers=office_headers())
|
||||
assert response.status_code == 200
|
||||
assert response.json()["record_id"] == record["id"]
|
||||
|
||||
def test_another_tenants_number_is_404(self, client):
|
||||
"""core-api T-7 / workspace D-73: another tenant's number answers
|
||||
404 not_found, exactly like a missing one — proven here by number
|
||||
1 existing for VALID_TENANT but not for OTHER_TENANT."""
|
||||
client.post(
|
||||
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
||||
)
|
||||
response = client.get("/book/records/1", headers=office_headers(tenant=OTHER_TENANT))
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_a_malformed_record_id_is_404_not_422(self, client):
|
||||
"""Neither digits nor a UUID — resolved as not_found rather than a
|
||||
422 pydantic validation error, now that the path param is a plain
|
||||
`str` (needed so a number can reach it at all)."""
|
||||
response = client.get("/book/records/not-a-valid-id", headers=office_headers())
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
|
||||
class TestWorkPersonalScopeTags:
|
||||
"""Book v1 point 2: `work`/`personal` are tags (D-69) — set via `tag`,
|
||||
refused via `untag` of the record's current scope."""
|
||||
|
||||
def test_tagging_work_sets_scope_and_replaces_personal_in_tags(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
assert record["scope"] == "personal"
|
||||
|
||||
response = client.post(
|
||||
f"/book/records/{record['id']}/tag", json={"tag": "work"}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["scope"] == "work"
|
||||
assert "work" in response.json()["tags"]
|
||||
assert "personal" not in response.json()["tags"]
|
||||
|
||||
def test_untagging_the_current_scope_is_409_scope_required(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['id']}/untag", json={"tag": "personal"}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"]["code"] == "scope_required"
|
||||
|
||||
def test_untagging_the_other_scope_is_a_noop(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['id']}/untag", json={"tag": "work"}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["scope"] == "personal"
|
||||
|
||||
def test_tagging_work_writes_a_book_event(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
client.post(f"/book/records/{record['id']}/tag", json={"tag": "work"}, headers=office_headers())
|
||||
history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json()
|
||||
actions = [e["action"] for e in history["events"]]
|
||||
assert actions.count("tag") == 1 # the scope change, on top of the "add" event
|
||||
|
||||
|
||||
class TestArchiveAndTrash:
|
||||
"""Book v1 point 3."""
|
||||
|
||||
def test_archiving_sets_archived_at_and_leaves_the_inbox(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
archived = client.patch(
|
||||
f"/book/records/{record['id']}", json={"archived": True}, headers=office_headers()
|
||||
).json()
|
||||
assert archived["archived_at"] is not None
|
||||
|
||||
archive_view = client.get(
|
||||
"/book/records", params={"view": "archive"}, headers=office_headers()
|
||||
).json()
|
||||
assert archive_view["total"] == 1
|
||||
assert archive_view["records"][0]["id"] == record["id"]
|
||||
|
||||
inbox_view = client.get(
|
||||
"/book/records", params={"view": "inbox"}, headers=office_headers()
|
||||
).json()
|
||||
assert inbox_view["total"] == 0
|
||||
|
||||
def test_unarchiving_clears_archived_at(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
client.patch(f"/book/records/{record['id']}", json={"archived": True}, headers=office_headers())
|
||||
unarchived = client.patch(
|
||||
f"/book/records/{record['id']}", json={"archived": False}, headers=office_headers()
|
||||
).json()
|
||||
assert unarchived["archived_at"] is None
|
||||
|
||||
def test_trash_wins_over_archive_in_view_archive(self, client):
|
||||
"""Dossier mutation check: letting `view=archive` include trashed
|
||||
records must make this test fail."""
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
client.patch(
|
||||
f"/book/records/{record['id']}",
|
||||
json={"archived": True, "trashed": True},
|
||||
headers=office_headers(),
|
||||
)
|
||||
|
||||
archive_view = client.get(
|
||||
"/book/records", params={"view": "archive"}, headers=office_headers()
|
||||
).json()
|
||||
assert archive_view["total"] == 0
|
||||
|
||||
trash_view = client.get(
|
||||
"/book/records", params={"view": "trash"}, headers=office_headers()
|
||||
).json()
|
||||
assert trash_view["total"] == 1
|
||||
assert trash_view["records"][0]["id"] == record["id"]
|
||||
|
||||
|
||||
class TestAppend:
|
||||
"""Book v1 point 4."""
|
||||
|
||||
def test_append_separates_with_a_blank_line(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "first line"}, headers=office_headers()
|
||||
).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['id']}/append", json={"text": "second line"}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["body"] == "first line\n\nsecond line"
|
||||
|
||||
def test_append_to_an_empty_body_just_sets_it(self, client):
|
||||
record = client.post("/book/records", json={"body": ""}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['id']}/append", json={"text": "hello"}, headers=office_headers()
|
||||
)
|
||||
assert response.json()["body"] == "hello"
|
||||
|
||||
def test_append_writes_a_book_event(self, client):
|
||||
"""Dossier: "the event each new change writes — every write leaves
|
||||
one, as today"."""
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
client.post(f"/book/records/{record['id']}/append", json={"text": "more"}, headers=office_headers())
|
||||
history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json()
|
||||
assert "append" in [e["action"] for e in history["events"]]
|
||||
|
||||
|
||||
class TestChecklistToggle:
|
||||
"""Book v1 point 5."""
|
||||
|
||||
def test_checking_an_item_by_position(self, client):
|
||||
record = client.post(
|
||||
"/book/records",
|
||||
json={
|
||||
"kind": "task", "body": "x",
|
||||
"checklist": [{"text": "one", "position": 0}, {"text": "two", "position": 1}],
|
||||
},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['id']}/checklist",
|
||||
json={"position": 1, "checked": True},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
items = {item["position"]: item["checked"] for item in response.json()["checklist"]}
|
||||
assert items[1] is True
|
||||
assert items[0] is False
|
||||
|
||||
def test_unknown_position_is_404_checklist_item_not_found(self, client):
|
||||
record = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "x", "checklist": [{"text": "one", "position": 0}]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['id']}/checklist",
|
||||
json={"position": 99, "checked": True},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "checklist_item_not_found"
|
||||
|
||||
def test_checklist_toggle_writes_a_book_event(self, client):
|
||||
record = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "x", "checklist": [{"text": "one", "position": 0}]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
client.post(
|
||||
f"/book/records/{record['id']}/checklist",
|
||||
json={"position": 0, "checked": True},
|
||||
headers=office_headers(),
|
||||
)
|
||||
history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json()
|
||||
assert "checklist" in [e["action"] for e in history["events"]]
|
||||
|
||||
|
||||
class TestViewDue:
|
||||
"""Book v1 point 6."""
|
||||
|
||||
def test_due_lists_open_tasks_due_today_or_earlier_soonest_first(self, client):
|
||||
now = datetime.now(timezone.utc)
|
||||
overdue = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "overdue", "due_at": (now - timedelta(days=2)).isoformat()},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
today_due = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "today", "due_at": now.isoformat()},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
future = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "future", "due_at": (now + timedelta(days=5)).isoformat()},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
|
||||
due_view = client.get("/book/records", params={"view": "due"}, headers=office_headers()).json()
|
||||
ids = [r["id"] for r in due_view["records"]]
|
||||
assert future["id"] not in ids
|
||||
assert ids == [overdue["id"], today_due["id"]]
|
||||
|
||||
def test_due_excludes_archived_and_trashed(self, client):
|
||||
now = datetime.now(timezone.utc)
|
||||
task = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "overdue", "due_at": (now - timedelta(days=1)).isoformat()},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
client.patch(f"/book/records/{task['id']}", json={"archived": True}, headers=office_headers())
|
||||
|
||||
due_view = client.get("/book/records", params={"view": "due"}, headers=office_headers()).json()
|
||||
assert due_view["total"] == 0
|
||||
|
||||
|
||||
class TestNextRecord:
|
||||
"""Book v1 point 7."""
|
||||
|
||||
def test_next_returns_none_when_no_candidates(self, client):
|
||||
response = client.get("/book/records/next", headers=office_headers())
|
||||
assert response.status_code == 200
|
||||
assert response.json()["record"] is None
|
||||
|
||||
def test_next_prefers_the_soonest_due_task(self, client):
|
||||
now = datetime.now(timezone.utc)
|
||||
client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "later", "due_at": (now + timedelta(days=3)).isoformat()},
|
||||
headers=office_headers(),
|
||||
)
|
||||
sooner = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "sooner", "due_at": (now + timedelta(days=1)).isoformat()},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
|
||||
response = client.get("/book/records/next", headers=office_headers())
|
||||
assert response.json()["record"]["id"] == sooner["id"]
|
||||
|
||||
def test_next_excludes_a_task_whose_start_at_is_in_the_future(self, client):
|
||||
now = datetime.now(timezone.utc)
|
||||
client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "not yet", "start_at": (now + timedelta(days=5)).isoformat()},
|
||||
headers=office_headers(),
|
||||
)
|
||||
response = client.get("/book/records/next", headers=office_headers())
|
||||
assert response.json()["record"] is None
|
||||
|
||||
def test_next_filters_by_energy_budget(self, client):
|
||||
client.post(
|
||||
"/book/records", json={"kind": "task", "body": "big", "energy": "high"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
response = client.get(
|
||||
"/book/records/next", params={"energy": "low"}, headers=office_headers()
|
||||
)
|
||||
assert response.json()["record"] is None
|
||||
|
||||
def test_next_prefers_pinned_when_due_dates_tie(self, client):
|
||||
client.post("/book/records", json={"kind": "task", "body": "plain"}, headers=office_headers())
|
||||
pinned = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "pinned", "pinned": True},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
|
||||
response = client.get("/book/records/next", headers=office_headers())
|
||||
assert response.json()["record"]["id"] == pinned["id"]
|
||||
|
||||
|
||||
class TestIdempotentAdd:
|
||||
"""Book v1 point 8."""
|
||||
|
||||
def test_same_key_within_24h_replays(self, client):
|
||||
"""Dossier mutation check: ignoring the idempotency key must make
|
||||
this test fail."""
|
||||
first = client.post(
|
||||
"/book/records", json={"body": "x", "idempotency_key": "key-1"}, headers=office_headers()
|
||||
)
|
||||
assert first.status_code == 201
|
||||
assert first.json()["replayed"] is False
|
||||
|
||||
second = client.post(
|
||||
"/book/records",
|
||||
json={"body": "different body, same key", "idempotency_key": "key-1"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["replayed"] is True
|
||||
assert second.json()["id"] == first.json()["id"]
|
||||
assert second.json()["body"] == "x" # the first record, not a new one
|
||||
|
||||
def test_different_keys_create_separate_records(self, client):
|
||||
first = client.post(
|
||||
"/book/records", json={"body": "a", "idempotency_key": "key-a"}, headers=office_headers()
|
||||
).json()
|
||||
second = client.post(
|
||||
"/book/records", json={"body": "b", "idempotency_key": "key-b"}, headers=office_headers()
|
||||
).json()
|
||||
assert first["id"] != second["id"]
|
||||
|
||||
def test_no_key_never_replays(self, client):
|
||||
first = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
second = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
assert first["id"] != second["id"]
|
||||
|
||||
|
||||
class TestSummary:
|
||||
"""Book v1 point 9."""
|
||||
|
||||
def test_summary_counts_each_bucket_excluding_archived_and_trashed(self, client):
|
||||
now = datetime.now(timezone.utc)
|
||||
# inbox: an unfiled Note plus an inbox-status Task (point 10)
|
||||
client.post("/book/records", json={"body": "unfiled note"}, headers=office_headers())
|
||||
client.post(
|
||||
"/book/records", json={"kind": "task", "body": "inbox task"}, headers=office_headers()
|
||||
)
|
||||
# today
|
||||
client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "due today", "status": "open", "due_at": now.isoformat()},
|
||||
headers=office_headers(),
|
||||
)
|
||||
# past_due
|
||||
client.post(
|
||||
"/book/records",
|
||||
json={
|
||||
"kind": "task", "body": "overdue", "status": "open",
|
||||
"due_at": (now - timedelta(days=2)).isoformat(),
|
||||
},
|
||||
headers=office_headers(),
|
||||
)
|
||||
# pinned — a waiting task, so it does not also land in "inbox"
|
||||
client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "pinned", "status": "waiting", "pinned": True},
|
||||
headers=office_headers(),
|
||||
)
|
||||
# done today
|
||||
done_task = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "finished"}, headers=office_headers()
|
||||
).json()
|
||||
client.post(
|
||||
f"/book/records/{done_task['id']}/close", json={"outcome": "done"}, headers=office_headers()
|
||||
)
|
||||
# archived — must not be counted anywhere
|
||||
archived = client.post(
|
||||
"/book/records", json={"body": "archived note"}, headers=office_headers()
|
||||
).json()
|
||||
client.patch(f"/book/records/{archived['id']}", json={"archived": True}, headers=office_headers())
|
||||
|
||||
summary = client.get("/book/summary", headers=office_headers()).json()
|
||||
assert summary["inbox"] == 2
|
||||
assert summary["today"] == 1
|
||||
assert summary["past_due"] == 1
|
||||
assert summary["pinned"] == 1
|
||||
assert summary["done_today"] == 1
|
||||
|
||||
|
||||
class TestInboxIncludesUnfiledNotes:
|
||||
"""Book v1 point 10 (amended 2026-10-20, found by the Office round):
|
||||
`view=inbox` lists tasks in status `inbox` **and** notes with no
|
||||
`parent` link; a note leaves the inbox once filed under a parent,
|
||||
archived or trashed."""
|
||||
|
||||
def test_an_unfiled_note_appears_in_the_inbox(self, client):
|
||||
"""Dossier mutation check: narrowing `_inbox_condition` back to
|
||||
`status == "inbox"` only must make this test fail — this is the
|
||||
exact bug the amendment fixes (a Note's status is `active`, never
|
||||
`inbox`)."""
|
||||
note = client.post(
|
||||
"/book/records", json={"body": "quick capture"}, headers=office_headers()
|
||||
).json()
|
||||
assert note["status"] == "active"
|
||||
|
||||
inbox = client.get("/book/records", params={"view": "inbox"}, headers=office_headers()).json()
|
||||
assert inbox["total"] == 1
|
||||
assert inbox["records"][0]["id"] == note["id"]
|
||||
|
||||
def test_filing_a_note_under_a_parent_removes_it_from_the_inbox(self, client):
|
||||
parent = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "project"}, headers=office_headers()
|
||||
).json()
|
||||
note = client.post("/book/records", json={"body": "a note"}, headers=office_headers()).json()
|
||||
|
||||
inbox_before = client.get(
|
||||
"/book/records", params={"view": "inbox"}, headers=office_headers()
|
||||
).json()
|
||||
assert any(r["id"] == note["id"] for r in inbox_before["records"])
|
||||
|
||||
client.post(
|
||||
f"/book/records/{note['id']}/link",
|
||||
json={"to_id": parent["id"], "relation": "parent"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
inbox_after = client.get(
|
||||
"/book/records", params={"view": "inbox"}, headers=office_headers()
|
||||
).json()
|
||||
assert not any(r["id"] == note["id"] for r in inbox_after["records"])
|
||||
|
||||
def test_a_note_created_with_a_parent_is_never_in_the_inbox(self, client):
|
||||
parent = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "project"}, headers=office_headers()
|
||||
).json()
|
||||
note = client.post(
|
||||
"/book/records",
|
||||
json={"body": "filed immediately", "parent": parent["id"]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
inbox = client.get("/book/records", params={"view": "inbox"}, headers=office_headers()).json()
|
||||
assert not any(r["id"] == note["id"] for r in inbox["records"])
|
||||
|
||||
def test_an_archived_note_is_not_in_the_inbox(self, client):
|
||||
note = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
client.patch(f"/book/records/{note['id']}", json={"archived": True}, headers=office_headers())
|
||||
inbox = client.get("/book/records", params={"view": "inbox"}, headers=office_headers()).json()
|
||||
assert not any(r["id"] == note["id"] for r in inbox["records"])
|
||||
|
||||
def test_a_task_due_today_and_closed_leaves_today_and_counts_as_done(self, client):
|
||||
"""Found by the live smoke test: closing a task due today left it in
|
||||
`today`, so the Office's glance pointed at work already finished."""
|
||||
now = datetime.now(timezone.utc)
|
||||
task = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "due today", "status": "open", "due_at": now.isoformat()},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
assert client.get("/book/summary", headers=office_headers()).json()["today"] == 1
|
||||
|
||||
client.post(f"/book/records/{task['id']}/close", json={"outcome": "done"}, headers=office_headers())
|
||||
|
||||
summary = client.get("/book/summary", headers=office_headers()).json()
|
||||
assert summary["today"] == 0
|
||||
assert summary["done_today"] == 1
|
||||
|
||||
def test_summarys_inbox_count_matches_the_inbox_view(self, client):
|
||||
client.post("/book/records", json={"body": "a note"}, headers=office_headers())
|
||||
client.post(
|
||||
"/book/records", json={"kind": "task", "body": "an inbox task"}, headers=office_headers()
|
||||
)
|
||||
inbox_view = client.get(
|
||||
"/book/records", params={"view": "inbox"}, headers=office_headers()
|
||||
).json()
|
||||
summary = client.get("/book/summary", headers=office_headers()).json()
|
||||
assert summary["inbox"] == inbox_view["total"] == 2
|
||||
|
||||
@@ -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")
|
||||
@@ -107,6 +107,54 @@ class TestMigration005TenantAndNumber:
|
||||
assert found == "ix_book_records_tenant_kind_status"
|
||||
|
||||
|
||||
class TestMigration011RoundTrip:
|
||||
"""Migration 011 (Book v1 point 8, core-api T-2) must upgrade and
|
||||
downgrade cleanly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_adds_idempotency_key_column(self, test_database_url):
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
nullable = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT is_nullable FROM information_schema.columns "
|
||||
"WHERE table_name = 'book_records' AND column_name = 'idempotency_key'",
|
||||
)
|
||||
assert nullable == "YES"
|
||||
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT indexname FROM pg_indexes WHERE indexname = "
|
||||
"'ix_book_records_tenant_idempotency_key'",
|
||||
)
|
||||
assert found == "ix_book_records_tenant_idempotency_key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
|
||||
baseline = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert baseline.returncode == 0, baseline.stderr
|
||||
|
||||
down = run_alembic(test_database_url, "downgrade", "010")
|
||||
assert down.returncode == 0, down.stderr
|
||||
column_after_down = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'book_records' AND column_name = 'idempotency_key'",
|
||||
)
|
||||
assert column_after_down is None
|
||||
|
||||
# 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
|
||||
column_after_up = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'book_records' AND column_name = 'idempotency_key'",
|
||||
)
|
||||
assert column_after_up == "idempotency_key"
|
||||
|
||||
|
||||
class TestBookEventsAppendOnly:
|
||||
"""The trigger must block UPDATE and DELETE on book_events, no exceptions."""
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for conftest.py's own test-infrastructure fixtures.
|
||||
|
||||
core-api T-12: a failing database test used to print the test database
|
||||
password, because pytest renders a failing test's fixture arguments with
|
||||
`repr()` — no `-v` or `--showlocals` needed to trigger it, just a test that
|
||||
takes `test_database_url` and fails. `MaskedURL` (conftest.py) is the fix:
|
||||
same string, same connection, but a `repr()` that never shows the password.
|
||||
"""
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from tests.conftest import MaskedURL
|
||||
|
||||
|
||||
def test_database_url_fixture_renders_without_its_password(test_database_url):
|
||||
"""Mutation check: revert `MaskedURL.__repr__` to the inherited `str`
|
||||
one (or hand back a plain `str` from the fixture) and this fails — the
|
||||
real password would appear in `repr(test_database_url)`, exactly as it
|
||||
did in a failing test's traceback before T-12."""
|
||||
password = urlsplit(test_database_url).password
|
||||
assert password, "fixture must carry a real password for this test to mean anything"
|
||||
|
||||
rendered = repr(test_database_url)
|
||||
|
||||
assert password not in rendered
|
||||
# Host and database name are still visible — only the password is masked.
|
||||
assert "***" in rendered
|
||||
assert urlsplit(test_database_url).path in rendered
|
||||
|
||||
|
||||
def test_masked_url_is_still_a_fully_usable_string():
|
||||
"""The wrapper changes only `repr()`; every other string operation the
|
||||
rest of the suite relies on (`.replace()`, `urlsplit()`, use as a
|
||||
subprocess environment value) must see the real value unchanged."""
|
||||
raw = "postgresql://core_api_test:s3cr3t@localhost:5432/core_api_test"
|
||||
masked = MaskedURL(raw)
|
||||
|
||||
assert masked == raw
|
||||
assert str(masked) == raw
|
||||
assert masked.replace("postgresql://", "postgresql+asyncpg://", 1) == raw.replace(
|
||||
"postgresql://", "postgresql+asyncpg://", 1
|
||||
)
|
||||
assert urlsplit(masked).password == "s3cr3t"
|
||||
assert "s3cr3t" not in repr(masked)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests for core-api T-3's `make dev-test-server` target: it must refuse to
|
||||
start when the resolved database is not `core_api_test`, before uvicorn ever
|
||||
binds the port, and it must never print credentials while doing so."""
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from tests.conftest import REPO_ROOT
|
||||
|
||||
PORT = 8777
|
||||
|
||||
|
||||
def _port_is_free(port: int) -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
return s.connect_ex(("localhost", port)) != 0
|
||||
|
||||
|
||||
def test_refuses_when_resolved_database_is_not_core_api_test(test_database_url):
|
||||
password = urlsplit(test_database_url).password
|
||||
wrong = test_database_url.rsplit("/", 1)[0] + "/core_api"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["TEST_DATABASE_URL"] = wrong
|
||||
|
||||
result = subprocess.run(
|
||||
["make", "dev-test-server"],
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
combined = result.stdout + result.stderr
|
||||
assert result.returncode != 0
|
||||
assert "core_api_test" in combined
|
||||
if password:
|
||||
assert password not in combined
|
||||
assert _port_is_free(PORT), "dev-test-server must never bind the port when it refuses"
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user