Skip to content

Make personal integrations forward-only - #388

Merged
lilseyi merged 1 commit into
mainfrom
codex/forward-only-integrations
Sep 9, 2026
Merged

Make personal integrations forward-only#388
lilseyi merged 1 commit into
mainfrom
codex/forward-only-integrations

Conversation

@lilseyi

@lilseyi lilseyi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • remove user-triggered historical Google backfill from the console and backend start path
  • seed Gmail from the current profile history id at connect time, stop legacy Gmail backfill jobs cleanly, and make Google Chat first sync baseline at now
  • preserve Calendar/Chat destinations across reconnects and make iMessage first enable baseline to the current local Messages row instead of importing history
  • update integrations copy to be personal-brain only and avoid exposing cursor/backfill jargon in the UI

Verification

  • pnpm --filter @context/convex test -- googleConnect calendarConnect chatProduct
  • JEST_DISABLE_WATCHMAN=1 pnpm --filter @context/mobile test -- googleConnectionsCard --watchman=false
  • /Users/lilseyi/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node test/test.mjs (apps/mcp)
  • pnpm --filter @context/convex typecheck
  • pnpm --filter @context/mobile typecheck
  • pnpm --filter @context/desktop typecheck
  • /Users/lilseyi/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --experimental-strip-types -e '...' direct runImessageServiceChecks

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.

@lilseyi
lilseyi force-pushed the codex/forward-only-integrations branch from 7299370 to b835123 Compare September 9, 2026 14:04
@lilseyi
lilseyi force-pushed the codex/forward-only-integrations branch from b835123 to c00d9e3 Compare September 9, 2026 14:09
@lilseyi
lilseyi merged commit 562b0fc into main Sep 9, 2026
26 checks passed
@lilseyi
lilseyi deleted the codex/forward-only-integrations branch September 9, 2026 14:15
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>
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