Skip to content

feat(db): inbound-ingestion persistence schema — migrations 009-012 (HT-36) - #36

Merged
zaridan merged 3 commits into
mainfrom
feat/ht-36-inbound-persistence-schema
Jul 14, 2026
Merged

feat(db): inbound-ingestion persistence schema — migrations 009-012 (HT-36)#36
zaridan merged 3 commits into
mainfrom
feat/ht-36-inbound-persistence-schema

Conversation

@zaridan

@zaridan zaridan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Implements HT-36 [C] — the inbound-ingestion persistence schema, under the HT-33 epic, against the merged spec. Schema + migration tests only — no store methods or pipeline code (later tickets).

New migrations (009–012), all mailbox-namespaced

  • 009 mailboxes — the isolation anchor. address UNIQUE (gmail-push.md §3 needs an emailAddress to resolve to exactly one mailbox); status CHECK active | paused | needs_reconnect; provider deliberately un-CHECKed so a new transport adapter needs no migration.
  • 010 mailbox_oauth_tokens — per-mailbox OAuth secrets, 1:1 by mailbox_id. Both the refresh token and the short-lived access token are stored as ciphertext (bytea) — see the security note below. HT-38 owns the actual crypto; this only reserves the columns.
  • 011 gmail_watch_state — per-mailbox Gmail historyId cursor + watch() expiry, kept out of the provider-agnostic mailboxes table (a non-Gmail transport touches nothing here). history_id is text (Gmail's own wire type — an opaque watermark, never arithmetic'd).
  • 012 inbound_deliveries — the delivery ledger. UNIQUE (mailbox_id, provider_message_id) is the claim key the pipeline's INSERT … ON CONFLICT DO NOTHING targets (spec §3/§4). conversation_id/thread_id are ON DELETE SET NULL, not CASCADE — the ingestion fact survives even if its conversation is later removed (invariant HT-3: clean-room protocol doc #1).

Tests cover every constraint, default, FK, CASCADE-vs-SET-NULL, and the exact ON CONFLICT claim + cross-mailbox behavior. Gates green: typecheck, biome, 403 tests.

One security change I made on review (worth your eye)

The ticket/spec listed access_token as a plaintext column. I changed it to access_token_ciphertext (bytea). An access token is itself a bearer credential granting ~1h of live mailbox access; storing it plaintext beside an encrypted refresh token would hand a DB thief that ~1h window for free, defeating the point of encrypting the refresh token. Encrypting both means a DB dump alone yields zero mailbox access without HT-38's key. Small change — flagging rather than burying it.

Decisions carried through (all sound)

  • dead-letter spelled with a hyphen to match the spec verbatim (the ticket had a stray dead_letter underscore — the spec wins; there's a test asserting the underscore is rejected).
  • 1:1 sidecar tables key on mailbox_id as PK; the ledger gets a surrogate id + a separate unique claim index (mirrors threads).
  • No cross-column CHECK tying ledger statusconversation_id nullability, and no status index beyond the claim key — both deferred to the ticket that builds the consuming store methods/queries, so we don't lock in an invariant before its edge cases are settled.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added database migrations introducing mailboxes, per-mailbox OAuth token storage, Gmail watch state, and an inbound delivery tracking ledger with constrained statuses and deduplication.
  • Bug Fixes
    • Expanded migration test coverage for new migrations (009–012), including constraint validation, conflict/no-op behavior, idempotency, and cascade/nullable foreign-key cleanup.
    • Updated Postgres schema verification to match the configured schema.
  • Documentation
    • Updated inbound-ingestion delivery ledger contract to store only threadId for outcomes and clarified retry behavior accordingly.

…HT-36)

Adds the durable state the inbound pipeline needs, all mailbox-namespaced: 009 mailboxes (isolation anchor; address UNIQUE, status CHECK); 010 mailbox_oauth_tokens (per-mailbox OAuth secrets); 011 gmail_watch_state (Gmail historyId cursor + watch expiry, kept out of the provider-agnostic mailboxes table); 012 inbound_deliveries (delivery ledger with a UNIQUE (mailbox_id, provider_message_id) claim key — the idempotency record, claim/lease, and retry queue, spec §4).

SECURITY: both the refresh token AND the short-lived access token are stored as ciphertext (bytea), not plaintext — a DB dump alone must not yield usable mailbox access; HT-38 owns the crypto. inbound_deliveries.conversation_id/thread_id are ON DELETE SET NULL so the ingestion fact survives conversation deletion (invariant #1).

Schema + migration tests only; no store methods or pipeline code. Implements specs/mail/inbound-ingestion.md §4 + gmail-push.md §4/§6. Gates green: typecheck, biome lint, 403 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 727d33f9-c198-4d18-bc42-7cf80f8ab3da

📥 Commits

Reviewing files that changed from the base of the PR and between 0a26058 and fce12bf.

📒 Files selected for processing (1)
  • specs/mail/inbound-ingestion.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • specs/mail/inbound-ingestion.md

📝 Walkthrough

Walkthrough

Four forward-only migrations add mailbox, OAuth token, Gmail watch state, and inbound delivery tables. Tests cover their constraints, foreign-key actions, migration ledger entries, idempotency, and configured-schema table expectations. The inbound-ingestion specification now records only threadId for delivery outcomes.

Changes

Mailbox and delivery schema

Layer / File(s) Summary
Define and register migrations
src/db/migrate.ts
Migrations 009–012 add mailboxes, mailbox_oauth_tokens, gmail_watch_state, and inbound_deliveries with keys, constraints, timestamps, and foreign-key actions, then register them in order.
Validate schema behavior
src/db/migrate.test.ts, src/db/postgres.test.ts
Tests verify migration ledger updates, idempotency, table constraints, bytea round-tripping, cascade deletion, nullable pointers, conflict handling, SET NULL behavior, and the configured-schema table list.
Align delivery-ledger contract
specs/mail/inbound-ingestion.md
The delivery ledger now records only threadId, with conversationId derived from threads.conversationId during retry handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant migrate
  participant MIGRATIONS
  participant Database
  participant _migrations
  migrate->>MIGRATIONS: Read migrations 009-012
  migrate->>Database: Apply pending SQL
  Database-->>migrate: Create mailbox and delivery tables
  migrate->>_migrations: Record migration ids and names
  migrate->>Database: Re-run migration checks
  Database-->>migrate: Return no pending migrations
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding inbound-ingestion persistence schema migrations 009-012.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-36-inbound-persistence-schema

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/db/migrate.ts`:
- Around line 554-555: Prevent mismatched conversation and thread outcomes by
updating the delivery-outcome constraints in src/db/migrate.ts lines 554-555 so
any non-null thread_id must belong to the specified conversation_id, or remove
the redundant reference as appropriate. Add a rejection test in
src/db/migrate.test.ts lines 822-826 using valid IDs from different
conversations, verifying the insert fails.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e673a184-cf03-48b1-af35-cb7fc5bdc8da

📥 Commits

Reviewing files that changed from the base of the PR and between 532a924 and 4623126.

📒 Files selected for processing (3)
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/db/postgres.test.ts

Comment thread src/db/migrate.ts Outdated
…t, HT-36)

Two independent FKs (conversation_id, thread_id) let a ledger row pair a conversation with a thread from a DIFFERENT conversation — a corrupt outcome. A thread already belongs to exactly one conversation (threads.conversation_id NOT NULL), so thread_id alone records the outcome and the conversation is derivable; removing the redundant column makes the mismatch impossible by construction rather than merely checked. spec §4 wording and the migration test updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@specs/mail/inbound-ingestion.md`:
- Around line 110-114: Update the atomicity contract near the “claim, store
write, and outcome” statement to refer only to recording the resulting threadId,
consistent with the ledger schema and Migration 012. Remove the plural “ids”
wording and any implication that conversationId is persisted; preserve the retry
behavior of returning the stored threadId without rewriting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0784252b-e784-4e27-baaa-d4643d638a0c

📥 Commits

Reviewing files that changed from the base of the PR and between 4623126 and 0a26058.

📒 Files selected for processing (3)
  • specs/mail/inbound-ingestion.md
  • src/db/migrate.test.ts
  • src/db/migrate.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/db/migrate.ts
  • src/db/migrate.test.ts

Comment thread specs/mail/inbound-ingestion.md
…act (CodeRabbit, HT-36)

Two remaining phrases in §3 step 5 and §4 ('resulting conversationId/threadId', 'resulting ids') still implied the removed conversation_id column. Both now say 'resulting threadId', consistent with migration 012.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant