Make personal integrations forward-only - #388
Merged
Merged
Conversation
lilseyi
force-pushed
the
codex/forward-only-integrations
branch
from
September 9, 2026 14:04
7299370 to
b835123
Compare
lilseyi
force-pushed
the
codex/forward-only-integrations
branch
from
September 9, 2026 14:09
b835123 to
c00d9e3
Compare
lilseyi
added a commit
that referenced
this pull request
Sep 12, 2026
…#405) * Advance a connected Google account's cursor on a schedule 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 * Prove the sync loop's guards, and stop it rewriting unchanged days 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 * Show whether a connected account is actually syncing, and let its owner 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 * Record why the sync is a pull, why five minutes, and what a longer gap 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 * Break the googleConnect/googleSync import cycle before it costs somebody 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 * Report a pass that could not start, instead of stranding its claim 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 * Make the pass refuse a workspace and a connection that do not agree 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 * Do not show a Calendar panel a schedule that only advances mail 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 * Say plainly what the sweep's disconnected belt is for 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 * Answer the fence-nonce question calendar-sync left for this work `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 * Do not put a number on the gap that is not in the record Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W42G6GPAm2Nh3nCxp2ir9D * Stop a truncated history walk from reporting the mailbox head as the 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 * Drive a truncated history walk through the whole pass, not just the client 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 * Name the one assumption in the truncation fix that only Google can settle 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
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.
Summary
Verification
Note: the full desktop harness still reaches an unrelated loopback listener test and fails with EPERM on 127.0.0.1 in this environment after the iMessage service checks pass.