Sync a connected Google account forward, on a schedule its owner sets - #405
Merged
Conversation
A person connects Gmail, the grant and a historyId baseline are stored, and nothing has ever advanced that cursor: no cron entry, no webhook, and the gateway's scheduled() handler has no trigger configured. The Gmail pipeline that would have done the work — apps/mcp/src/communications/gmailSync.js — was imported by nothing at all after #388 removed the historical backfill that used to import it. This is the trigger that was missing, not a second pipeline: - a five-minute cron sweeping connections that are actually due (now >= lastSyncAt + interval), claiming each one so a pass still running is never overtaken, and holding no decision of its own; - a per-connection syncIntervalMinutes, owner-only, floored at five minutes server-side and defaulting to fifteen; - one forward pass per account inside the existing credential barrier, which re-asks every gate before it opens a credential and advances historyId only over mail it actually wrote. Forward-only per #388: an expired cursor is re-baselined and the gap is recorded, never backfilled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
Forty-three checks over the sweep's due selection, the five-minute floor, the owner gate (attacker and victim in one database), the not-overtaking guard, and the cursor — including eight that drive the real runFileOperation against a fixture Gmail and an in-memory S3, so the pass under test is the pass that ships. The idempotence check found a defect the historical backfill also had: passing a wall-clock `now` into runIncrementalSync travels straight through syncOneDay to renderDay, overriding the `updated` default that is keyed to the newest message's own timestamp. A one-shot import survives that; a pass that runs every few minutes would rewrite every touched day forever. The loop passes no `now`, and "re-running the same pass writes no new bytes" is the check that holds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
…er say how often A connected mailbox that had never synced once and one syncing perfectly rendered the same card, which is exactly the state every connection in this product was in. The connection listing now carries the schedule — how often it is polled, whether it has ever read mail, when it is next due, and the last failure, kept after a later pass succeeded — and the card says which of the two states this is in one sentence. The interval picker is owner-only by being drawn only where GoogleActions is, which is absent rather than disabled for anybody else. It starts at the floor and does not enforce it: a value below five minutes is refused by the mutation and the refusal is rendered, so the check that matters is the server's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
…p costs The section phase 1 left open said a live sync needed an internet-facing route on the control plane and a trigger on the gateway. It needed neither: the token mint and the storage binding are already here, and runFileOperation is already the one function allowed to open a bucket credential, so the loop runs there. That paragraph is corrected rather than deleted, because the gap it describes was real and the shape of the fix is the argument. Also written down: why Google's push path is deliberately not built and what it would still need underneath it, why the floor is the cron's own tick, what a person loses at each step of a longer interval (freshness, then nothing, then — past a week — actual mail, which is why the maximum is a day), and what Calendar and Chat each still need to join the same loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
…ody a day The console listing imported the loop's status view and the loop imported the connect module's helpers, which closed a cycle in the Convex module graph. That does not fail loudly. It fails as an export that is *sometimes* missing depending on which module the loader entered first — observed here as `revokeGoogleGrant` resolving to "no such export" in roughly one full-suite run in six, failing two tests in `account.test.ts` about deleting an account, which touches neither file. The shared half is now a leaf, `functions/lib/googleSchedule.ts`, that imports nothing from `functions/`: the interval arithmetic, the due rule, the product list and the console's status view. Both files import it and neither imports the other. Worth recording how close this came to being mis-fixed: the visible symptom was a timing flake in an unrelated suite that appeared only when a test file was added, and treating it as one — a patient drain, a wait for the effect — made it rarer without touching the cause. Eight consecutive full runs green, where six runs previously failed once or twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
Two findings from sabotaging the guards one at a time. Minting the access token sat outside the pass's try block, so a throw from it — an envelope that will not open, a deployment missing its encryption key — would escape into the scheduler with syncStartedAt still set: the connection goes quiet for fifteen minutes and the row says nothing about why. It is inside now, and the check drives a pass that cannot mint at all. And the check that the cursor never advances past mail that was not written was weaker than it read: it never offered the mutation a cursor to advance to, so the call site could have handed one over and stayed green. It offers one now, and adding a cursor patch to the failed branch turns it red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
Self-review of the diff. A forward pass is driven by two arguments: the workspaceId whose bucket credential is opened, and the connectionId whose mail is read. Nothing built a mismatched pair — the sweep reads both off one row — but a pair is what a tenant boundary is made of, and no function checked that the two belonged together. A mismatched pass would have filed one context's mail into another context's bucket. googleForwardSyncJob now takes both and answers null unless the connection belongs to the workspace, which is the only place that can hold this rule because it is the only function that sees both. The check also exposed a smaller cross-tenant write in the same path: a null job still called the record mutation, so a pass driven with somebody else's connection released that connection's claim and pushed its next sync out. A null job now writes nothing at all. Proved with attacker and victim in one database, each owning a real personal context with a real connection, in both directions, and with the pass itself driven at a mismatched pair: no Gmail call, no bucket request, the victim's cursor and claim untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
The schedule belongs to the account — one grant, one pass — but the pass walks Gmail and only Gmail today, so drawing "every 15 minutes, next due at 10:15" on the Calendar or Chats panel promises something this loop does not yet do for them. It is drawn where Gmail is in view; their own status lines already say their sync is pending, honestly. When they join the loop, the condition is what goes. Also tidies the schema block the scheduling fields were inserted into, so the byte counter is not sitting inside a list of scheduling fields, and makes the note about the import cycle say what was actually observed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
It is unreachable while the index it sits behind is right, which is the point of it: sabotaging the belt fails nothing and sabotaging the index fails eleven checks. The comment should say it catches the day somebody changes the index, rather than implying it catches anything today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
`calendar-sync.js` names the nonce as a cross-cutting question for whoever builds the live sync trigger, and this is that trigger, so it should not go in silently. Gmail's is the connection's own id — the same value the removed backfill used, so a day rewritten by either path keeps its message anchors, and one a sender cannot derive from anything they can see. Calendar's, derived from account and date, both printed in the note, is the one that is actually weak. The stronger shape already exists next door as Chat's stored `nonceSeed`, and adopting it for mail would rename the fence markers in every day already written — so it is recorded as a follow-up with the migration it needs, rather than done quietly here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
…cursor Adversarial review found the one defect in this design that loses mail silently, and it is the invariant the PR, the module header and the decision doc all claim as load-bearing. `history.list` returns the mailbox's CURRENT historyId on every page, not a per-page cursor. `listAllHistory` walks at most fifty pages and used to stop without signalling it — so a truncated walk handed back the head, the pass recorded "synced" with it, and everything behind page fifty was skipped forever: no gap, row reading active. It fires hardest on the first pass against a connection whose baseline is weeks old, which is exactly the case this loop was built for. Raising the page limit moves the cliff, and leaving the cursor put never finishes. So the walk now reports `truncated` and carries `lastRecordId` — a history record's own id, a valid startHistoryId covering precisely the records this pass collected — and the pass stores that instead of the head, setting `syncCatchUp` so the connection is due on the next tick whatever its interval. Each pass makes real progress, the flag clears when the walk reaches the end, and the console says "catching up on older mail" rather than naming a next due time it does not mean. Four checks in the gateway suite pin it, including the one this needed first: a paging fixture. The old fixture never returned a nextPageToken, so paging was entirely unexercised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
…lient Sabotaging the one line that reports truncation to the row — `catchUp: truncated` in the pass — left all 2,408 checks green. The gateway suite proved the client reports truncation and the control-plane suite proved the mutation handles it, and nothing at all proved the wire between them, which is exactly where a fix like this gets dropped in a later refactor. The Convex fixture could not have caught it: its history endpoint never returned a `nextPageToken`, so paging was unreachable from that side. It pages now, one record per page with the mailbox head on every one of them, and a pass against fifty-one pages asserts the row stores the last record walked rather than the head, stays due, and that the next pass carries on from there and stores the head only once the walk actually reaches the end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
…ttle A history record's id is used as the next startHistoryId. Google's docs say that value class is what startHistoryId takes, and nothing here has asked Google — so the doc says which sentence to check first, and what the failure looks like if it is wrong: a 404 on the next pass, which this code already reads as an expired cursor and records as a gap. Wrong in the safe direction, and visible on the row rather than silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D
Contributor
Author
|
Rebased onto current main after the durable move, managed-storage, Fast Search, and observability merges. Revalidation on Node 22: 128 focused Convex Google sync/connect tests passed; Convex and mobile typechecks passed; 42 focused mobile integration tests passed; full MCP/gateway suite passed. The production path remains forward-only and credential access stays behind the existing internal action barrier. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A person connects Gmail in settings, the grant is stored and a
historyIdbaseline is recorded — and nothing has ever advanced that cursor. There is no
cron entry, there is no webhook, and the gateway's
scheduled()handler has no[triggers]block configured (and reaches only the single-tenant legacy pathwhen it does fire).
apps/mcp/src/communications/gmailSync.js— a complete,fixture-tested Gmail pipeline — was imported by nothing at all after #388
removed the historical backfill that used to import it.
So this is the trigger that was missing, not a second pipeline. No new Gmail
fetcher was written:
runIncrementalSyncandgetProfileHistoryIdcome backinto
functions/files.tsexactly whererunBackfillused to be called from.What it does
sweepDueGoogleSyncs, that starts a pass only forconnections that are actually due (
now >= lastSyncAt + interval), claimseach one it starts, and skips anything claimed less than fifteen minutes ago
so a pass still running is never overtaken. The cron decides only when to
look; every question about whether a connection may sync is re-asked by the
pass itself, before a credential is opened. (Due-ness is the one thing that
is the sweep's alone — stated in
crons.tsrather than glossed.)syncIntervalMinutes, owner-only, with a floor of fiveminutes refused server-side and a ceiling of one day. The default is
fifteen: three passes an hour at a third of the floor's cost.
asks the row before it asks for a credential, advances
historyIdonly overmail it actually wrote, re-baselines an expired cursor forward and records the
gap rather than smoothing it over, and reports every exit — including the ones
that did nothing — so a claim is never stranded.
read mail, whether it is draining a backlog, when it is next due, and the last
failure kept after a later pass succeeded.
Forward-only throughout. #388's decision stands.
Adversarial review, second round — four findings, all fixed
Blocking: a truncated history walk reported the mailbox head as the cursor.
history.listreturns the mailbox's currenthistoryIdon every page, not aper-page cursor, and
listAllHistorystopped at fifty pages without signallingit. A truncated walk therefore stored "caught up" while holding only the first
pages, and everything behind them was skipped forever — no
gapDetected,row reading
active, firing hardest on the first pass against a weeks-oldbaseline. Raising the page limit moves the cliff; leaving the cursor put never
finishes. The walk now reports
truncatedand carrieslastRecordId— ahistory record's own id, a valid
startHistoryIdcovering exactly the recordsthis pass collected — the pass stores that, and
syncCatchUpkeeps theconnection due on the next tick whatever its interval, so each pass drains
further and the flag clears when the walk reaches the end. The argument, and
the two rejected alternatives, are in
docs/decisions/communications.md.A revoked grant was hammered forever. The failed branch overwrote
health: "reconnect_required"with a plainerror, and the pass's skip gatekeys on that state — so a grant Google had revoked was offered back to its token
endpoint on every backoff, and the console never showed the one state the owner
could act on. Health is now preserved, proved through the real sequence
(
markReconnectRequired, then a failed pass) rather than by patching the rowinto place.
The quota stopped counting exactly where it matters. The failed branch
dropped
bytesWritten, and the one caller passing a non-zero figure is thequota path — which stops after writing whole days.
bytesAlreadyUsedfrozebelow the ceiling, so the same days were rewritten and re-dropped forever and
the ceiling was never crossable. Counted now, with an end-to-end check.
everSyncedwas true after passes that read nothing. A baseline and a gapre-baseline are successful passes that read no mail, by design; counting them
as syncs showed a freshly connected mailbox as current before a message had
been read.
gmail.lastSyncedAtmoves only when mail was read,cursorReadyiswhat a baseline makes true, and the card has three sentences where it had two.
Taken as far as I judged right: the three unguarded error classifications now
have checks (a rate-limited owner must not be told to reconnect); the flat
15-minute retry is now a ladder to a six-hour cap with a per-connection spread;
writeDayPartdoes the read-compare on stores without conditional writes too,so idempotence is a property of this code rather than of R2;
crons.ts'spreamble now admits the second class of job rather than filing an engine beside
the sweeps; and every pass that writes leaves an audit row against
boundBy—counts only, and never for a poll that found nothing.
Earlier round, for the record
A circular import between
googleConnect.tsandgoogleSync.ts(an exportsometimes missing, surfacing as an unrelated flaky test); the pass would have
rewritten every touched day forever by passing a wall-clock
nowintorenderDay; andworkspaceId/connectionIdwere never checked against eachother, which would have filed one context's mail into another's bucket.
Tests
Convex 2,339 → 2,410. Mobile 4,475 → 4,484. Gateway 3,113 → 3,122.
Second sabotage round: 18 more, all 18 red, each isolated to the checks naming
the guard — including one that was green on the first attempt (the single line
wiring truncation into the row), which is why the Convex fixture now pages.
Across both rounds: 43 sabotages, 41 red; the two that stayed green are the
sweep's unreachable-by-construction belt and a no-op sabotage, both since
converted or documented.
What is unexercised
Nothing here has ever contacted Google — every call is a fixture, and that
sentence is now in the decision doc as well as this description, because the
doc outlives the PR. Page shapes, rate-limit reasons and the real
historyIdlifetime are documentation rather than observation; Google says "typically at
least a week" and "in rare circumstances only a few hours", which means even a
one-day interval can gap, and the doc says so. The loop is also inert wherever
MAIL_CONNECT_ENABLEDis not"true".Calendar and Chat are deliberately not half-done; what each needs is written
out in
docs/decisions/communications.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D