Skip to content

Sync a connected Google account forward, on a schedule its owner sets - #405

Merged
lilseyi merged 15 commits into
mainfrom
claude/confident-pasteur-fali1w-googlesync
Sep 12, 2026
Merged

Sync a connected Google account forward, on a schedule its owner sets#405
lilseyi merged 15 commits into
mainfrom
claude/confident-pasteur-fali1w-googlesync

Conversation

@lilseyi

@lilseyi lilseyi commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

A person connects Gmail in settings, the grant is stored and a historyId
baseline 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 path
when 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: runIncrementalSync and getProfileHistoryId come back
into functions/files.ts exactly where runBackfill used to be called from.

What it does

  • A five-minute cron, sweepDueGoogleSyncs, that starts a pass only for
    connections that are actually due (now >= lastSyncAt + interval), claims
    each 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.ts rather than glossed.)
  • A per-connection syncIntervalMinutes, owner-only, with a floor of five
    minutes 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.
  • One forward pass per account, inside the existing credential barrier. It
    asks the row before it asks for a credential, advances historyId only over
    mail 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.
  • Honest status: how often this account is polled, whether it has ever
    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.list returns the mailbox's current historyId on every page, not a
per-page cursor, and listAllHistory stopped at fifty pages without signalling
it. 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-old
baseline. Raising the page limit moves the cliff; leaving the cursor put never
finishes. The walk now reports truncated and carries lastRecordId — a
history record's own id, a valid startHistoryId covering exactly the records
this pass collected — the pass stores that, and syncCatchUp keeps the
connection 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 plain error, and the pass's skip gate
keys 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 row
into place.

The quota stopped counting exactly where it matters. The failed branch
dropped bytesWritten, and the one caller passing a non-zero figure is the
quota path — which stops after writing whole days. bytesAlreadyUsed froze
below 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.

everSynced was true after passes that read nothing. A baseline and a gap
re-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.lastSyncedAt moves only when mail was read, cursorReady is
what 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;
writeDayPart does the read-compare on stores without conditional writes too,
so idempotence is a property of this code rather than of R2; crons.ts's
preamble 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.ts and googleSync.ts (an export
sometimes missing, surfacing as an unrelated flaky test); the pass would have
rewritten every touched day forever by passing a wall-clock now into
renderDay; and workspaceId/connectionId were never checked against each
other, 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 historyId
lifetime 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_ENABLED is 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

claude and others added 15 commits September 10, 2026 04:08
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
…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
@lilseyi

lilseyi commented Sep 12, 2026

Copy link
Copy Markdown
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.

@lilseyi
lilseyi merged commit d9fb8ee into main Sep 12, 2026
26 checks passed
@lilseyi
lilseyi deleted the claude/confident-pasteur-fali1w-googlesync branch September 12, 2026 04:50
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.

2 participants