Skip to content

docs(spec): inbound ingestion + Gmail-push behavioral spec (HT-34) - #34

Merged
zaridan merged 3 commits into
mainfrom
feat/ht-34-inbound-ingestion-spec
Jul 13, 2026
Merged

docs(spec): inbound ingestion + Gmail-push behavioral spec (HT-34)#34
zaridan merged 3 commits into
mainfrom
feat/ht-34-inbound-ingestion-spec

Conversation

@zaridan

@zaridan zaridan commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Filed HT-34 [A] — the behavioral spec that pins every downstream decision in the HT-33 Gmail-OAuth inbound intake epic before any engine code touches the front door.

What's here

Two specs under specs/mail/:

  • inbound-ingestion.md — the provider-agnostic pipeline (raw → parseInboundEmaildecideThreading → store). This is the orchestration threading.md/sending.md keep deferring to as "the mail-ingestion pipeline, not yet built." The future forwarding transport reuses it unchanged.
  • gmail-push.md — the Gmail push transport (Pub/Sub receipt + security, history reconciliation, cursor, watch() renewal) — the workspace-native mode.

Decisions pinned

  • Raw-message provider boundary — the transport yields raw RFC822 + metadata; we parse exactly once, by our own code. Corrects the current InboundEmailProvider seam (→ HT-35).
  • Idempotency on (mailboxId, providerMessageId)not the optional, sender-controlled RFC Message-ID.
  • Transactional cursor advancement + at-least-once ingest with a dead-letter ledger — never drop a customer's mail (invariant HT-3: clean-room protocol doc #1), never reprocess forever.
  • Own-message loop suppression (new; contradicts no fixture).
  • Expired-cursor policy (dogfood): pause + manual rebaseline, not an unbounded auto-resync.

One deliberate divergence — flagged for your call

The spec does not suppress generic third-party Auto-Submitted/bulk mail by default, because fixtures/mail/observed/auto-submitted.json shows the reference system ingesting it — suppressing would diverge from an observed fixture, which charter §2 says needs explicit written justification. It's left as an explicit open question (inbound-ingestion.md §5), not smuggled in. (Codex had recommended dropping such mail; I held to the fixture instead — that's the judgment I'd most like a second opinion on.)

Follow-ups (kept out of this PR to stay surgical)

  • threading.md §5 and STATUS.md still read "the mail-ingestion pipeline (not yet built)" / "a future auto-responder spec" — both are now homed here. Happy to update those cross-refs in a small follow-up.

Reviewed independently by Codex against the codebase before the epic was filed. Nothing else touched.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added a Gmail “push” inbound transport specification using Pub/Sub webhooks with Google-signed OIDC JWT verification and strict envelope binding.
    • Defined minimal webhook behavior: enqueue mailbox reconciliation and quickly acknowledge Pub/Sub.
    • Specified reconciliation using stored history cursors to fetch raw RFC822 messages, with monotonic cursor advancement, dedup on duplicates, and batching failure recovery.
    • Documented expired-cursor handling (pause for manual rebaseline) and daily bounded sweeps with serialized mailbox reconciliation.
    • Added a provider-agnostic inbound ingestion pipeline spec covering at-least-once/idempotent delivery, dedup + retry/dead-letter ledger, loop/suppression rules, and acceptance criteria.

Adds specs/mail/inbound-ingestion.md (provider-agnostic ingest pipeline) and specs/mail/gmail-push.md (Gmail push transport) — the behavioral contract HT-35..HT-44 build against.

Pins: raw-message provider boundary (parse once, by our code); idempotency on (mailboxId, providerMessageId) rather than the sender-controlled RFC Message-ID; transactional cursor advancement; at-least-once ingest with a dead-letter ledger; own-message loop suppression; and the dogfood expired-cursor policy (pause + manual rebaseline, not auto-resync).

Flags one deliberate divergence per charter §2: does NOT suppress generic third-party Auto-Submitted/bulk mail by default (auto-submitted.json shows the reference system ingesting it) — left as an explicit open question, not a silent change.

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

coderabbitai Bot commented Jul 13, 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: e4d7e9cb-d614-422b-af76-6272163372ea

📥 Commits

Reviewing files that changed from the base of the PR and between 8635128 and c7f4a72.

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

📝 Walkthrough

Walkthrough

Adds specifications for a provider-agnostic inbound email ingestion pipeline and a Gmail Pub/Sub push transport, covering raw-message processing, authentication, reconciliation, idempotency, cursor handling, lifecycle renewal, suppression, and acceptance scenarios.

Changes

Inbound mail processing

Layer / File(s) Summary
Pipeline contract and ingestion procedure
specs/mail/inbound-ingestion.md
Defines raw RFC822 transport inputs, parsing and threading invariants, ordered ingestion, storage fallbacks, and attachment handling.
Idempotency, suppression, and observability
specs/mail/inbound-ingestion.md
Defines delivery-ledger states, retries, transactional outcomes, loop suppression, auto-responder handling, observability, scope boundaries, and acceptance cases.
Gmail push authentication and reconciliation
specs/mail/gmail-push.md
Defines authenticated Pub/Sub receipt, bounded history reconciliation, raw Gmail message retrieval, metadata delivery, and monotonic cursor advancement.
Gmail watch lifecycle and acceptance behavior
specs/mail/gmail-push.md
Defines expired-cursor handling, watch renewal, daily reconciliation, ownership boundaries, and fake Gmail/Pub/Sub acceptance criteria.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PubSub
  participant GmailWebhook
  participant GmailAPI
  participant IngestPipeline
  participant MailStore
  PubSub->>GmailWebhook: Send authenticated notification
  GmailWebhook->>GmailAPI: Reconcile history from stored cursor
  GmailAPI-->>GmailWebhook: Return changed messages
  GmailWebhook->>GmailAPI: Fetch raw RFC822 messages
  GmailAPI-->>GmailWebhook: Return raw message bytes
  GmailWebhook->>IngestPipeline: Submit message bytes and metadata
  IngestPipeline->>MailStore: Store or suppress message
  MailStore-->>IngestPipeline: Confirm outcome
  IngestPipeline-->>GmailWebhook: Confirm batch completion
  GmailWebhook->>MailStore: Persist advanced cursor
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 two new mail behavior specs and is concise enough for history scanning.
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-34-inbound-ingestion-spec

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: 8

🤖 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/gmail-push.md`:
- Around line 44-47: Update the OIDC JWT verification requirements in the Gmail
push authentication documentation to require the email_verified claim to be
present and true, in addition to the existing signature, issuer, audience,
email, and expiration checks.
- Around line 48-50: Update the Gmail push authentication specification to
require an explicit allowlist check on the push envelope’s subscription resource
name. The handler must compare that value with the configured expected
subscription or allowlist in addition to validating the JWT and service account,
and reject notifications from any other subscription.
- Around line 66-76: Update the Gmail push reconciliation flow to resolve and
validate the mailbox using the notification’s emailAddress before recording the
cursor or invoking users.history.list. Reject mismatched or unresolved
notifications, and only continue with the resolved mailboxId for history
reconciliation and ingestion.
- Around line 109-119: Extend the daily mailbox renewal flow around
SchedulerProvider.registerCron and Gmail watch() so it also reconciles each
active mailbox’s cursor via a bounded history.list sync. Ensure missed or
delayed notifications are recovered without introducing an unbounded polling
loop, while preserving the existing daily watch re-arming behavior.

In `@specs/mail/inbound-ingestion.md`:
- Line 136: Update the human-support-staff reference in the inbound-ingestion
documentation sentence to use “an Agent” instead of “an agent,” preserving the
surrounding wording and meaning.
- Around line 122-128: Revise the “Loop suppression” rule so matching our
sending identity in From or Return-Path alone never drops a message. Require a
verifiable correlation, such as an exact outbound Message-ID, provider
sent-message identity, or signed reply token; use sender identity only as
supporting evidence, while preserving the rate-cap backstop.
- Around line 57-78: Update the inbound-ingestion flow to atomically claim or
lease the delivery-ledger key `(mailboxId, providerMessageId)` before
processing, preventing concurrent deliveries from both proceeding. Make
`createConversation` and `appendThread` idempotent for that key, or wrap the
ledger claim, store operation, and `stored` outcome update in a
transaction/outbox so retries return the existing outcome without duplicating
writes.
- Around line 33-36: Align the shared InboundEmailProvider contract and all
adapters so each delivery exposes raw RFC822 bytes (or a blob reference)
together with provider metadata, without engine-facing message parsing. Update
specs/mail/inbound-ingestion.md lines 33-36 to document this contract and its
adapter requirements; update specs/mail/gmail-push.md lines 71-76 so Gmail
follows the same raw-byte hand-off and places attachment extraction and blob
ownership in the agreed layer.
🪄 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: 1a2db3d7-8f2d-4885-b454-f04c920725c2

📥 Commits

Reviewing files that changed from the base of the PR and between b0c982d and aae5ef8.

📒 Files selected for processing (2)
  • specs/mail/gmail-push.md
  • specs/mail/inbound-ingestion.md

Comment thread specs/mail/gmail-push.md Outdated
Comment thread specs/mail/gmail-push.md Outdated
Comment thread specs/mail/gmail-push.md Outdated
Comment thread specs/mail/gmail-push.md Outdated
Comment thread specs/mail/inbound-ingestion.md Outdated
Comment thread specs/mail/inbound-ingestion.md Outdated
Comment thread specs/mail/inbound-ingestion.md Outdated
Comment thread specs/mail/inbound-ingestion.md Outdated
…sh (HT-34)

All 8 findings incorporated.

gmail-push: require email_verified=true on the Pub/Sub OIDC JWT; bind to the push envelope's exact subscription field; resolve emailAddress->mailbox (reject on mismatch) before reconciling; add a bounded daily history.list reconciliation sweep so dropped/delayed pushes cannot leave a mailbox stale.

inbound-ingestion: attachment extraction is the pipeline's job (post-parse), not the transport's; make the ledger claim atomic (unique-key get-or-insert) and commit the store write + 'stored' outcome in one transaction (closes the concurrent-delivery and partial-failure double-create windows); loop suppression now requires a verifiable correlation (own Message-ID / valid own token) — sender identity alone never drops mail (invariant #1); 'Agent' capitalization.

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.

🧹 Nitpick comments (2)
specs/mail/gmail-push.md (2)

140-150: 🚀 Performance & Scalability | 🔵 Trivial

Consider per-mailbox mutual exclusion between push-triggered reconciliation and the daily sweep.

Push-triggered reconciliation (§3) and this daily sweep both read/advance the same stored cursor for a mailbox and can run concurrently (e.g., a push arrives mid-sweep). Per §4 each run independently gates its own cursor advance on confirmed storage, so this isn't a drop risk, but concurrent overlapping runs mean redundant history.list/messages.get calls and duplicate (deduped, but still wasted) ingest work against the same mailbox. A brief note on a per-mailbox lock/lease for reconciliation runs would close this gap.

🤖 Prompt for 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.

In `@specs/mail/gmail-push.md` around lines 140 - 150, Add a brief note to the
daily sweep design describing per-mailbox mutual exclusion, using a lock or
lease shared by push-triggered reconciliation and the sweep. State that
overlapping runs for the same mailbox must be serialized or skipped, while
preserving concurrent reconciliation across different mailboxes and the existing
cursor/storage guarantees.

66-73: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Specify what triggers §3 reconciliation after the fast ack.

The handler acks Pub/Sub quickly and "lets the reconciliation step (§3) do the fetching," but the hand-off mechanism from "durable marker recorded" to "history.list actually runs" is unspecified (queue consumer, waitUntil-style deferred execution, separate worker poll, etc.). Since push is meant to be the near-real-time path — the §6 daily sweep is explicitly the 24h-bounded fallback, not the primary path — a silently-dropped hand-off degrades push to "eventually caught by the sweep" without anyone noticing. Worth naming the mechanism explicitly so an implementer doesn't accidentally do the fetch inline (defeating "no heavy work inline") or drop it entirely in a serverless runtime that doesn't guarantee post-response execution.

🤖 Prompt for 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.

In `@specs/mail/gmail-push.md` around lines 66 - 73, Specify the concrete hand-off
that triggers §3 reconciliation after the durable notification marker is
recorded and the fast Pub/Sub acknowledgment is returned, such as an explicit
queue consumer or separate worker poll. Ensure the mechanism is reliable in the
target runtime, preserves near-real-time processing, and does not perform Gmail
or persistence fetches inline or rely on unguaranteed post-response execution.
🤖 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.

Nitpick comments:
In `@specs/mail/gmail-push.md`:
- Around line 140-150: Add a brief note to the daily sweep design describing
per-mailbox mutual exclusion, using a lock or lease shared by push-triggered
reconciliation and the sweep. State that overlapping runs for the same mailbox
must be serialized or skipped, while preserving concurrent reconciliation across
different mailboxes and the existing cursor/storage guarantees.
- Around line 66-73: Specify the concrete hand-off that triggers §3
reconciliation after the durable notification marker is recorded and the fast
Pub/Sub acknowledgment is returned, such as an explicit queue consumer or
separate worker poll. Ensure the mechanism is reliable in the target runtime,
preserves near-real-time processing, and does not perform Gmail or persistence
fetches inline or rely on unguaranteed post-response execution.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c5d9b177-8a49-4d9d-864a-c84c71878cc7

📥 Commits

Reviewing files that changed from the base of the PR and between aae5ef8 and 8635128.

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

gmail-push §2: the webhook enqueues the reconcile job onto QueueProvider (a durable hand-off, not an unguaranteed serverless post-response continuation) so a dropped hand-off can't silently degrade push to the daily sweep.

gmail-push §6: serialize reconciliation per mailbox via a lease (the inbound analogue of the outbound delivery lease, sending.md §3a) so push-triggered and swept reconciliation don't do redundant work; different mailboxes stay concurrent.

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