Skip to content

fix(google-content-sync): harden against the poison-page class, make the snapshot obligation durable, type-check the lambda - #1621

Merged
krishagel merged 9 commits into
devfrom
fix/google-sync-hardening
Aug 10, 2026
Merged

fix(google-content-sync): harden against the poison-page class, make the snapshot obligation durable, type-check the lambda#1621
krishagel merged 9 commits into
devfrom
fix/google-sync-hardening

Conversation

@krishagel

@krishagel krishagel commented Aug 8, 2026

Copy link
Copy Markdown
Member

Summary

Hardens the Google Drive content-sync pipeline against the whole class of poison-page failures that caused the 2026-08-06 prod incident, closes the durable-obligation gap around selection snapshots, and puts the lambda under the TypeScript compiler for the first time.

Stacks on #1617. This branch is cut from claude/google-sync-queue-backlog-b836ae; until #1617 merges, its two commits appear in this diff. Merge #1617 first, then this.

Follow-up scope approved by @hagelk after #1617 deliberately shipped root-cause-only.

Gap 1 — all-or-nothing page parsing (lib/repositories/google-drive/drive-client.ts)

changesListSchema/filesListSchema used to z.array(entrySchema).parse() the whole payload: one malformed entry among 1000 rejected the page, the cursor never advanced, and the connector stalled — the original fileId incident, generalized.

  • Per-entry validation: list envelopes now parse entries as unknown[], then validate one at a time (parseDriveEntries). A malformed member costs that one entry, not the page. Skipped entries surface as skippedEntries (index, best-effort id, zod issues) and are logged loudly at every call site. A parse failure is never reinterpreted as a removal — dropped entries suppress the unseen-source sweep instead (see Gap 2).
  • Validators loosened to Google's actual contract, each with a comment tying it to its consumer: modifiedTime/time plain strings (consumers do new Date(...)); size plain string (the streaming byte bound is authoritative); shortcutDetails.targetId/targetMimeType optional (resolveShortcut already has the graceful "no target" path; targetMimeType has no reader); .nullish() + normalizing transforms where Google could send explicit null.
  • webViewLink/iconLink: strict .url() dropped, but a non-http(s) scheme (javascript:, data:) is discarded rather than persisted, so the loosening cannot open an href-injection path.
  • getFile() (single-entity sibling of the same gap): now safeParses and throws a typed GoogleDriveUnreadableFileError (fileId + issues) instead of a raw ZodError. Callers classify it explicitly: a change-processing failure fails that one record (recordSourceFailure, never markSourceMissing, cursor advances); an unreadable shortcut target or selection root during enumeration counts as a skipped entry. Found by adversarial review of the first commit — a ZodError escaping getFile mid-change-page would have replayed the page until the DLQ, the exact pattern this PR eliminates.

Gap 2 — snapshot obligation now durable (infra/lambdas/google-content-sync/)

Previously, a change page that demanded a selection snapshot suppressed every per-page cursor write until the snapshot finished. A 900s Lambda timeout or budget failure threw away all change-page work and replayed it from the original cursor — indefinitely, if the snapshot kept failing.

  • New reconcile.ts extracts the changes-feed loop against injected collaborators (same pattern as changes.ts/safety.ts; index.ts cannot be imported from the root jest gate). Ordering invariants, each behaviorally tested:
    1. The obligation is written durably (metadata.selectionSnapshotPendingAt, jsonb merge — no migration needed) before the cursor moves past the page that raised it.
    2. The cursor persists after every page, unconditionally.
    3. The flag clears only after a complete snapshot; an incomplete one (dropped entries) retains it, so the next run retries.
  • On sync start, a pending obligation is discharged before consuming new pages (resolveSyncCursor).
  • shouldRetireUnseenSources: markUnseenSourcesMissing only runs after a complete enumeration — a dropped entry says nothing about its file's existence, so retiring on partial data would be silent data loss.
  • reconcileInitial records the obligation on an incomplete initial/410-rebuild too (setSelectionSnapshotPending(context, !complete)), aligning it with the changes-loop path (correctness-review finding).

Gap 3 — lambda now type-checked

infra/lambdas/google-content-sync/ had no tsconfig and was excluded from both the root and infra tsc runs — esbuild only transpiles. Added tsconfig.json (modeled on unified-content-processor) and typecheck:google-content-sync, wired into infra's build script exactly like the sibling check. The duplicate local re-derivation of GoogleDriveChange in index.ts is replaced by the exported type.

Deliberately unchanged

Verification

  • bun run lint — zero warnings
  • bun run typecheck + typecheck:google-content-sync — clean
  • Full jest suite: 574 suites / 5465 passed (one unrelated document-generation-service xlsx test hit its 5s timeout under machine load; passes in isolation in 1.1s)
  • Full local Playwright gate: 306 passed / 40 skipped; one nexus/model-router failure + 10 flaky-passed-on-retry, all Nexus/Atrium UI specs untouched by this backend diff. The failed spec was rerun in isolation and passed (10 passed / 1 flaky-passed-on-retry / 0 failed) — load-induced flake from two sessions' gates sharing the machine, not a regression
  • New coverage: 16 drive-client contract tests (per-entry skip, null tolerance, scheme filtering, shortcut edge cases, typed getFile error), 12 reconcile-loop behavior tests (cursor/obligation ordering, incomplete-snapshot retention), change-scope wiring assertions

Named E2E flows: none — backend Lambda path, not reachable via Playwright; covered by unit/contract tests above.

…ning the sync queue

The prod alarm aistudio-prod-google-content-sync-oldest-message fired because
two connectors (b1429b0b-..., 1f870c92-...) had synced nothing since
2026-08-06 ~12:00 UTC and 904 messages had accumulated in the DLQ.

Root cause
----------
Google's Drive Changes feed (GET /drive/v3/changes) is not exclusively
file-scoped. Entries with changeType "drive" describe the Shared Drive itself
- rename, membership change, restriction change - and carry NO fileId.

driveChangeSchema in lib/repositories/google-drive/drive-client.ts declared
fileId as a required z.string(), so changesListSchema.parse() threw a ZodError
inside listChanges the moment such an entry appeared in a page. The throw
happened before reconcileChanges could persist the cursor, so every SQS
redelivery re-fetched the identical page and failed identically. The message
never drained, its age breached the 30-minute alarm threshold, and after
maxReceiveCount it landed in the DLQ - repeatedly.

Changes
-------
lib/repositories/google-drive/drive-client.ts
  - driveChangeSchema.fileId is now z.string().optional().
  - Added changeType: z.string().optional(). Deliberately a plain string and
    NOT a z.enum: an enum would re-poison the queue the first time Google
    ships a changeType value we did not anticipate.
  - listChanges now requests changeType in its fields projection, so the API
    actually returns the discriminator the guard reads. Without this the
    projection would silently strip it and every entry would look untyped.

infra/lambdas/google-content-sync/changes.ts (new)
  - isFileScopedDriveChange(): the single place that decides whether a change
    entry names a file the sync path can act on. Returns false for
    changeType === "drive" and for a missing/empty fileId; a type predicate so
    the caller gets fileId narrowed to string. Extracted into its own module
    because index.ts is not importable from the root Jest gate.

infra/lambdas/google-content-sync/index.ts
  - processGoogleDriveChange() now guards FIRST: a non-file-scoped entry is
    logged and skipped with return false, before markSourceMissing() or any
    other side effect can run. Returning false leaves requiresSelectionSnapshot
    untouched, so reconcileChanges still persists the cursor for that page and
    the loop advances.
  - Drive deletion and access loss remain covered by the existing 403/404 ->
    markConnectorAccessLost path; nothing about that behaviour changes.
  - Extracted the catch-block outcome recording into recordDriveChangeFailure()
    - behaviour-identical, and it brings the function back under the
    complexity-15 lint ceiling that the new guard pushed it over.

Tests
-----
  - tests/unit/lib/repositories/google-drive-changes-feed.test.ts: a mixed page
    containing a changeType "drive" entry with no fileId plus a normal file
    entry now parses and returns both values; an unknown future changeType
    parses; and the fields projection is asserted to request changeType.
  - tests/unit/google-content-sync-change-scope.test.ts: full truth table for
    isFileScopedDriveChange (drive-scope wins even if a fileId is present,
    empty/missing fileId skipped, unknown changeType with a fileId processed),
    the fileId type narrowing, plus source-level assertions that the guard
    precedes the first side effect in processGoogleDriveChange and that
    reconcileChanges still persists the cursor on the skip path.

Operational notes
-----------------
No alarm thresholds changed. Existing DLQ messages are intentionally left to
expire under the 14-day retention rather than being redriven or purged. The
affected connectors self-heal on their next webhook or scheduled tick once the
Lambda is deployed.
…s removed

Review follow-up (Codex P1 on PR #1617).

The first commit skipped every non-file-scoped change entry unconditionally.
That is correct for the metadata noise the outage was actually about - Shared
Drive rename, membership change, restriction change - but it also silently
consumed the one drive-scoped entry that carries real meaning: removed: true,
which Google emits when the Shared Drive is deleted or the account loses
access to it.

Consuming that is a data-correctness bug. Sources already imported from that
drive become unreachable, and the connector gets no other signal:

  - A connector scoped to a shared drive would eventually surface the loss,
    because its next listChanges/watch call against that driveId returns
    403/404 and the existing markConnectorAccessLost path fires.
  - A connector reading the global changes feed would NOT. Its subsequent
    listChanges and watch calls keep succeeding, so the stale content stays
    active indefinitely with nothing to retire it.

Fix
---
infra/lambdas/google-content-sync/changes.ts
  - New isDriveRemovalChange(): true when an entry is not file-scoped and
    reports removed. Deliberately NOT keyed on changeType === "drive" - the
    legacy "teamDrive" value and any future scope Google introduces must take
    this path too. What identifies the case is "removed, and not about a
    specific file", which the caller has already established.
  - DriveChangeIdentity gained the optional removed flag.

infra/lambdas/google-content-sync/index.ts
  - processGoogleDriveChange() now returns isDriveRemovalChange(change) from
    the non-file-scoped branch rather than a bare false. Returning true sets
    requiresSelectionSnapshot, so reconcileChanges runs
    reconcileSelectionSnapshot -> markUnseenSourcesMissing and retires
    whatever is no longer reachable; a hard access loss still surfaces
    through the existing 403/404 -> markConnectorAccessLost path.
  - The cursor advances on both branches. Skips take the per-page
    persistSyncCursor; removals take the post-snapshot persistSyncCursor. The
    invariant the outage broke stays intact either way.
  - The skip/removal log line now also carries driveId and time, so a spike in
    either can be correlated against Drive audit logs.

Tests
-----
tests/unit/google-content-sync-change-scope.test.ts
  - Truth table for isDriveRemovalChange: drive-scoped removal flagged;
    "teamDrive", an unknown future scope, and a changeType-less removal all
    covered; rename/membership noise (removed false or absent) ignored; a
    file-scoped removal explicitly NOT claimed, since that is the file path's
    job via markSourceMissing; a drive-scoped entry that also carries a fileId
    still treated as drive-scoped.
  - Wiring assertions that the removal signal is returned rather than
    swallowed, and that reconcileChanges persists the cursor on BOTH branches
    (asserted as exactly two persistSyncCursor call sites in the loop).

Verification: eslint --max-warnings 0 clean; 573 suites / 5434 tests passing
(the single failing suite, tests/unit/agent-workspace-identity.test.ts, fails
identically on the clean tree - @aws-sdk/client-ecs is absent from
package.json and bun.lock entirely).
…e class

Follow-up to the drive-scoped change-entry hotfix. That fix removed one
specific poison entry; these three changes remove the failure class it
belonged to, plus the blind spot that let it ship.

1. Per-entry parsing in the Drive client (lib/repositories/google-drive)

   changesListSchema and filesListSchema parsed their arrays as
   z.array(entrySchema) and .parse()d the whole payload, so ONE malformed
   entry among a thousand rejected the entire page. listChanges then threw
   before the cursor advanced and the connector retried the same page
   forever - the exact shape of the fileId incident.

   Two-pronged:

   (a) Validators that were stricter than Google's actual contract were
       loosened, each after checking its downstream reader:
         - shortcutDetails.targetId / targetMimeType -> optional. Only
           targetId is dereferenced (resolveShortcut, and the selections
           route), and both already raise a graceful "no target" error.
           targetMimeType has no reader anywhere, so requiring it could
           only ever poison a page.
         - modifiedTime / change.time: .datetime({offset:true}) -> plain
           string. Consumers are new Date(...) and a revision string.
         - size: the ^\d+$ regex is gone. assertGoogleSourceMetadataSize
           re-checks the numeric shape and the streaming byte bound is
           authoritative regardless.
         - webViewLink / iconLink: .url() -> plain string, but a
           non-http(s) scheme is DROPPED rather than persisted, so
           loosening the validator cannot open an href injection path.
         - file, driveId, parents, owners, trashed and the optional
           strings accept an explicit null as well as an omission.

   (b) Defense in depth: the list envelopes now parse entries as
       z.unknown() and safeParse each one individually. A failing entry is
       reported on the page as a GoogleDriveSkippedEntry (index, any
       extractable id, the zod issues) and DROPPED. It is never
       reinterpreted - in particular markSourceMissing is never called for
       it, because a parse failure says nothing about whether the file
       still exists and reclassifying it would trade a stalled queue for
       silent data loss. The accepted cost (a missed update until that
       file changes again) is stated in the log line the Lambda emits.

   Because a dropped entry makes an enumeration incomplete, the snapshot
   path now gates markUnseenSourcesMissing behind
   shouldRetireUnseenSources(): retiring unseen sources is only sound
   after a COMPLETE pass, so the sweep is skipped (loudly) and left to the
   next clean snapshot.

2. Durable selection-snapshot obligation (infra/lambdas/google-content-sync)

   reconcileChanges deferred persistSyncCursor for the whole run once any
   page demanded a selection snapshot. A 900s Lambda timeout or a snapshot
   budget failure therefore threw away every already-processed change page
   and replayed them from the original cursor on the next attempt -
   indefinitely if the snapshot kept failing.

   The obligation is now durable instead of implied by cursor position: a
   selectionSnapshotPendingAt timestamp on repository_connectors.metadata
   (existing jsonb - no migration), written before the cursor moves past
   the page that raised it, with the cursor advancing per page exactly as
   the non-snapshot path already did. The flag is cleared only after a
   COMPLETE snapshot, and every sync start discharges an outstanding
   obligation before consuming new changes. The flag is written with a
   jsonb merge/delete rather than a read-modify-write so it cannot clobber
   the webhook path's concurrent metadata writes.

   The loop itself moved to reconcile.ts behind injected collaborators, so
   the cursor/obligation ordering is provable without a database, Google
   credentials, or the Lambda's module-level environment requirements.
   Mid-enumeration checkpointing remains out of scope; partial enumeration
   still never feeds markUnseenSourcesMissing.

3. The lambda is now type-checked

   infra/lambdas/google-content-sync had no tsconfig.json: excluded from
   the root tsc ("exclude": ["infra"]) and from the infra tsc ("exclude":
   ["lambdas/**/*"]), with esbuild only transpiling. Adds a tsconfig.json
   modeled on unified-content-processor (extends the root config so the
   lambda's repo-root lib/ imports resolve) and a
   typecheck:google-content-sync script chained into infra's build, which
   is what the cdk-validate CI job runs - the same route
   typecheck:unified-content takes. Fixes the P2 that motivated it:
   GoogleDriveChange was re-derived locally via
   Awaited<ReturnType<...>>["values"][number] and now imports the exported
   type.

Tests: new google-content-sync-reconcile.test.ts covers cursor-advances-
per-page-while-pending, flag-written-before-the-cursor-moves, marked once,
cleared only on a complete snapshot, retained on snapshot throw, and the
unseen-sweep gate. google-drive-changes-feed.test.ts gains real-payload
coverage for every loosened field (including the javascript:/data: drop)
and for one-malformed-entry-among-many across listChanges, listChildren
and listSharedDriveFiles. The reconcile source-string assertion in
google-content-sync-change-scope.test.ts is replaced by those behavioral
tests and now only pins that index.ts still delegates to the shared loop.
…it ::text cast

`jsonb - $1` is defined for text, integer and text[]. The key arrives as an
untyped bind parameter, so the overload is resolved by Postgres preference
rather than by the statement. Casting the parameter to text states the intent
and removes the dependency on that preference.

Verified against local Postgres end to end: setting the flag merges
selectionSnapshotPendingAt into repository_connectors.metadata without
disturbing sibling keys, and clearing it removes only that key.
…leted rebuild

reconcileInitial gated the clear on the connector row loaded at the start of
the run. That row is stale on the path that matters: after a 410 cursor
expiry, reconcileChanges may already have written the flag during this same
run, so the in-memory copy still says "not pending" and the obligation
survived a full, complete rebuild that had just discharged it.

Removing an absent jsonb key is a no-op and an initial rebuild is rare, so the
clear is now unconditional on a complete snapshot.
… of failing the run

Adversarial review of the per-entry list parsing found the one remaining
door for the poison-page pattern: GoogleDriveClient.getFile() still
hard-parsed its response, so a single malformed single-entity record --
a shortcut target, a parent during the selection walk, or a selection
root -- threw a raw ZodError. recordDriveChangeFailure rethrows anything
it cannot classify, which failed the whole run before the page's cursor
persisted and replayed the same page until the DLQ.

Changes:

- drive-client.ts: getFile() now safeParses and throws a typed
  GoogleDriveUnreadableFileError carrying the fileId and the zod issues,
  sharing the issue formatting with parseDriveEntries via a new
  formatEntryIssues helper. The error class documents why its handling
  must be the opposite of a 403/404: the file very likely still exists,
  so no caller may reinterpret it as a removal.

- index.ts recordDriveChangeFailure: a new branch ahead of the catch-all
  rethrow fails the one record (counters.failed + recordSourceFailure)
  and returns. Never markSourceMissing, never rethrow -- the page's
  cursor keeps advancing. This covers the change-processing paths
  (resolveShortcut and the selectedViaForFile parent walk, both of which
  correctly propagate the error up to this classifier).

- index.ts enumerateInitialFiles: an unreadable shortcut target (caught
  in the add closure) or selection root (caught in the selection loop)
  is now recorded as a skipped entry via a shared recordUnreadable
  helper, marking the enumeration incomplete -- which suppresses
  markUnseenSourcesMissing exactly like a dropped list entry -- instead
  of failing the entire snapshot.

- index.ts reconcileInitial: an incomplete initial or 410-triggered
  rebuild now records the durable snapshot obligation
  (setSelectionSnapshotPending(context, !complete)) instead of silently
  proceeding, so the next run repeats the snapshot rather than leaving
  files hidden behind a dropped entry unrecovered until each happens to
  change again. This aligns the initial path with the changes-loop
  path, which already retried its obligation.

Untouched on purpose:

- retryDeferredDownloads already isolates generic per-source failures,
  so the new error flows into its existing recordSourceFailure path.
- The interactive selections route fails the picker request either way;
  the typed error is strictly more informative than the ZodError it
  replaces.

Tests: behavioral getFile coverage (typed error with fileId + issue
paths, healthy record parses) in the drive-client suite; wiring
assertions for the classifier ordering and the reconcileInitial
obligation in the change-scope suite, consistent with that file's
established source-reading pattern.
Copilot AI lite review requested due to automatic review settings August 8, 2026 03:20

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f621c7a0d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread infra/lambdas/google-content-sync/index.ts
Comment thread lib/repositories/google-drive/drive-client.ts Outdated
@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review: fix(google-content-sync) — poison-page hardening, durable snapshot obligation, lambda typecheck

I read through the full diff (changes.ts, reconcile.ts, index.ts, drive-client.ts, tsconfig.json/package.json, and all three new test files) and traced the error-handling paths for the new GoogleDriveUnreadableFileError and the cursor/obligation ordering in reconcileChangePages. Note this diff still bundles #1617's commits since it hasn't merged yet — my comments below are scoped to the incremental work in this PR (Gaps 1-3).

Correctness

  • Cursor/obligation ordering (reconcile.ts) is sound. Traced reconcileChangePages: markSnapshotPending() is called before persistCursor() within the same page-processing iteration, and persistCursor() runs unconditionally after every page regardless of the obligation flag. This closes the described gap correctly — a crash mid-run can only replay work, never lose the obligation. The google-content-sync-reconcile.test.ts suite exercises this ordering directly (mark-pending index < persist:cursor-2 index), which is the right way to pin an ordering invariant like this.
  • GoogleDriveUnreadableFileError is threaded through every getFile() call site correctly. I checked all four call sites (resolveShortcut, enumerateFileSelection's selection-root read, selectedViaForFile's parent walk, retryDeferredDownloads) and confirmed each is reached through a try/catch that either explicitly classifies the new error type or falls through to the generic recordSourceFailure catch-all rather than an isGoogleDriveMissingError-only branch that would have swallowed/misrouted it. This is exactly the kind of thing that's easy to miss when introducing a new thrown-error type into an existing multi-call-site codebase — good attention to detail.
  • shouldRetireUnseenSources gating is conservative in the right direction. Suppressing the unseen-source sweep on any skipped entry (list-parse failure or unreadable getFile) trades promptness for avoiding silent data loss, consistent with the stated design goal. Worth a gut-check with the team on whether one skipped entry out of a large page should really suppress retirement for the entire connector for that run — it's a defensible, documented tradeoff, not a bug.
  • setSelectionSnapshotPending's jsonb merge/subtract pattern matches the existing persistSyncCursor convention (same selectionRevision + status != revoked guard, same ConnectorSelectionChangedError on zero rows affected). Good consistency.
  • webViewLink/iconLink scheme filtering (/^https?:\/\//i) correctly closes the injection concern opened by dropping the strict .url() validator — non-http(s) schemes (javascript:, data:) are silently discarded rather than persisted. Confirmed test coverage for this in google-drive-changes-feed.test.ts.

Minor / non-blocking

  • Observability regression, tiny: the old code used a distinct operation label "googleContent.persistSnapshotCursor" for the cursor write that follows a selection-snapshot; the new unified reconcileChangePages loop always calls persistCursor with "googleContent.persistCursor". If any dashboards/alarms key off that specific operation name, they'll silently stop seeing it. Probably fine, but worth a quick check.
  • Source-string-inspection tests in google-content-sync-change-scope.test.ts (e.g. asserting guardIndex < firstSideEffect by slicing index.ts's source text) are a reasonable workaround given index.ts can't be imported outside the Lambda's module-level environment requirements, but they're brittle to pure refactors (renames, reformatting) that wouldn't change behavior. Not asking for a change — just flagging so a future "why did this test break" doesn't cost someone time.
  • optionalString's .nullish().transform(...).optional() chain is slightly redundant (the trailing .optional() after a transform that already maps null/undefined to undefined) — harmless, just a style nit.

Security

No concerns beyond the webViewLink/iconLink scheme-filtering point above, which is already handled correctly. No secrets, no new IAM surface, no SQL injection vectors (all Drizzle parameterized queries / sql tag with bound params).

Test coverage

Strong. The three new suites cover the three gaps proportionally to their risk:

  • google-drive-changes-feed.test.ts: per-entry validation/skip behavior, null tolerance, scheme filtering, shortcut edge cases, typed getFile error — good use of a mocked fetch at the client boundary.
  • google-content-sync-reconcile.test.ts: the ordering invariants for the reconcile loop, including the "snapshot throws" and "snapshot incomplete" cases, which are exactly the scenarios that mattered for the original incident.
  • google-content-sync-change-scope.test.ts: change classification plus the source-inspection guard-wiring tests noted above.

I didn't find anything I'd block on. The two "minor" items above are worth a look but aren't correctness or security issues.


Reviewed via automated PR review by Claude.

claude added 2 commits August 8, 2026 03:42
…alid Date

Codex P2 on PR #1621. Loosening `modifiedTime` to a plain string kept a
malformed value from poisoning the page, but let it reach
`sourceIdentityFields`, which hands it to `new Date(...)` and a timestamp
column. An Invalid Date fails the import transaction, and because the entry
*validated*, the snapshot never counts it as a skipped entry — so the cursor
advances, `shouldRetireUnseenSources` is not suppressed, and the file stays
unimported indefinitely instead of being retried.

- New `optionalTimestamp` validator: accepts anything Google sends (no
  rejected entries), keeps only what `Date.parse` actually accepts, and
  normalizes the rest to `undefined` so the field falls back to its
  absent behaviour.
- Applied to `googleDriveFileSchema.modifiedTime`. `driveChangeSchema.time`
  is left as a plain string: its only consumer is a log field, never `new
  Date(...)`.
- Contract test covering the normalization and that the entry is still
  returned rather than skipped.

Refs PR #1621 review comment 3739858653
Codex P1 on PR #1621. When a selected Shared Drive is deleted or access to it
is revoked, `processGoogleDriveChange` requests a selection snapshot so
`markUnseenSourcesMissing` can retire the sources imported from it. But
`enumerateInitialFiles` rethrew 403/404 specifically for
`selectionKind === "drive"`, so that snapshot never completed:
`handleSyncFailure` called `markConnectorAccessLost`, which marks *every*
source on the connector — file and folder selections that are still perfectly
readable included — and the deletion grace period then removed their content.
One removed drive escalated into connector-wide data loss.

- The missing-error branch in `enumerateInitialFiles` no longer excludes
  `drive` selections: the selection is counted as inaccessible, logged with
  its `selectionKind`, and enumeration continues. Retirement is left to
  `markUnseenSourcesMissing`, which only retires what the enumeration did not
  see, so unrelated selections are untouched and an individually shared file
  living in the removed drive survives if another selection still reaches it.
- A total loss of access still converges: every selection lands in the same
  branch, the seen set is empty, and the sweep retires all sources into the
  same grace period `markConnectorAccessLost` would have used
  (`applyDeletionGrace` covers both `missing` and `access_lost`).
- Corrected the now-stale comment in `processGoogleDriveChange` that claimed
  a hard access loss surfaces through the 403/404 path.
- Guard-wiring test pinning that the branch is not gated on `selectionKind`.

Refs PR #1621 review comment 3739858648

Copy link
Copy Markdown
Member Author

🤖 pr-fix routine round 1 complete.

  • Review comments addressed: 2 of 2 actionable (Codex P1 + P2, both confirmed real)
  • CI failures fixed: 0 (all 10 checks were green before this round)
  • Commits pushed: 2
Finding Commit Fix
P1 — drive removal escalates to connector-wide access loss 47ccea8 enumerateInitialFiles' missing-error branch no longer excludes drive selections; retirement is scoped to the unseen sweep
P2 — unparseable modifiedTime becomes an Invalid Date aa079f4 New optionalTimestamp validator normalizes unparseable values to undefined

Two things worth a human eye:

  1. The PR description is now slightly stale. "Deliberately unchanged" says enumerateInitialFiles' 403/404-on-selection-root behavior is "not widened here" — P1's fix does change it, for drive selections specifically. I left your prose alone rather than rewriting it; worth a one-line edit before merge.
  2. access_lost as a distinct status on a fully revoked connector. With P1's fix, a connector whose every selection 403s now retires its sources as missing rather than access_lost. Content still converges to deleted (applyDeletionGrace covers both statuses), but the connector-level signal changes. Restoring the distinction would need an all-selections-inaccessible guard, which felt like a redesign beyond the review comment — flagging rather than deciding it.

Verification caveat — read before merging. I could not run the local gate: bun install fails in this routine's sandbox with 403 on npm tarball fetches (registry.npmjs.org is blocked by the egress policy here), so lint / typecheck / jest did not run locally. The two new tests were validated by executing their source-slice assertions directly against index.ts, and both fixes are small and localized, but CI is the authoritative gate for this round — do not merge on my say-so alone if the checks below are not green.

The Claude review comment on this PR was read and needed no code changes: its three "minor / non-blocking" items (the persistSnapshotCursor operation-label change, the brittleness of source-inspection tests, and the redundant trailing .optional() on optionalString) are observations and style notes, not defects. The first is the only one with any operational edge — worth confirming no dashboard keys off that operation name.


Generated by Claude Code

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review: fix(google-content-sync) — poison-page hardening, durable snapshot obligation, lambda typecheck

Thorough PR, well-documented, and well-tested (16 drive-client contract tests, 12 reconcile-loop behavior tests, change-scope wiring assertions). The core design — per-entry validation instead of all-or-nothing page parsing, and a durable selectionSnapshotPendingAt obligation instead of an implicit "cursor didn't advance" — is sound and matches the incident description.

One finding that I think is worth addressing before merge:

resolveShortcut's "no target" error is not actually graceful, and can still poison a page

resolveShortcut (index.ts:863-871) throws a plain Error when a shortcut's shortcutDetails.targetId is missing:

if (!targetId) throw new Error(`Drive shortcut ${file.id} has no target`);

This PR loosens shortcutDetails.targetId from required (z.string()) to optional (optionalString), with the comment "resolveShortcut already has the graceful 'no target' path" (drive-client.ts, and repeated in the new test file at google-drive-changes-feed.test.ts:267). That claim doesn't hold up:

  • In enumerateInitialFiles's add() callback (index.ts:1012-1022), only GoogleDriveUnreadableFileError is caught and turned into a per-entry skip; anything else is rethrown. A plain Error propagates up through collectFolderChildren/enumerateFileSelection/enumerateSharedDriveSelection, and the per-selection catch in enumerateInitialFiles (index.ts:1054-1088) only special-cases GoogleDriveUnreadableFileError and isGoogleDriveMissingError — a bare Error hits throw error and aborts the entire selection snapshot, not just the one selection.
  • In processGoogleDriveChange (index.ts:1536-1557), the same resolveShortcut call is wrapped by recordDriveChangeFailure, which also only special-cases the connector-lifecycle errors, GoogleDriveUnreadableFileError, and isGoogleDriveMissingError; a bare Error falls through to if (!isGoogleDriveMissingError(error)) throw error; and rethrows. Since reconcileChangePages only calls persistCursor after the full for (const change of page.values) loop completes, this throw happens before the cursor is persisted for that page — the exact "one bad entry poisons the page, cursor never advances" failure mode this PR sets out to eliminate (Gap 1 / Gap 2), just relocated from Zod validation to resolveShortcut.

Concretely: a shortcut whose target file was deleted (a real, documented Google Drive state — Drive doesn't retroactively clean up shortcutDetails when the target goes away) now parses successfully per-entry (that part is the intended fix), but then deterministically throws on every run that reaches it, stalling either the changes-feed cursor or the whole selection snapshot indefinitely — no skippedEntries/GoogleDriveSkippedEntry accounting, no recordSourceFailure, just an uncaught exception. This is untested — the new google-drive-changes-feed.test.ts test at line ~267 only asserts the schema accepts a shortcut missing targetId; nothing exercises what happens when resolveShortcut is actually called on that entry.

Suggested fix: give resolveShortcut a typed error (or reuse GoogleDriveUnreadableFileError) for the missing-target case so both call sites treat it the same as an unreadable file record — skip/fail that one entry, keep the cursor/snapshot moving — rather than a raw Error that falls through every classification branch.

Minor / non-blocking

  • enumerateInitialFiles's recordSkipped/recordUnreadable log every skipped entry at log.error with the full issues array. If a misbehaving/large Shared Drive returns many malformed entries in one page (plausible — that's exactly the scenario this PR defends against), this could generate a lot of CloudWatch volume per run. Not a correctness issue, just worth keeping an eye on cost/noise; a per-run count-and-sample rather than one line per entry might be worth considering if this shows up in practice.
  • Nice touch: the size field being loosened from a regex-validated string to optionalString is safe because assertSizeWithinLimit (safety.ts) already tolerates non-numeric strings by skipping the check rather than throwing — so the streaming byte bound genuinely stays authoritative as the comment claims. Confirmed this isn't a bypass.
  • optionalWebUrl's scheme allowlist (/^https?:\/\//i) is the right call for the stated href-injection concern, and is covered by tests (javascript:/data: dropped).

Test coverage / security / performance

No concerns beyond the finding above — the new unit tests are unusually thorough (ordering invariants for the durable obligation, per-entry skip behavior, null-tolerance, scheme filtering). Security-sensitive changes (URL scheme filtering, typed error boundaries) are each backed by a regression test. No DB migration needed (jsonb merge onto existing metadata column), consistent with CLAUDE.md's migration-immutability rules. infra/package.json's build script correctly wires in the new typecheck:google-content-sync step.

Automated review finding on PR #1621, and a real regression this PR
introduced. Loosening `shortcutDetails.targetId` from `z.string()` to
optional makes the "shortcut with no target" state reachable on a
well-formed page — Drive does not retroactively clean up `shortcutDetails`
when a target is deleted — but `resolveShortcut` still threw a bare `Error`
for it, and a bare `Error` falls through every classification branch:

- `enumerateInitialFiles`' `add()` catches only
  `GoogleDriveUnreadableFileError` and rethrows everything else, and the
  per-selection catch classifies only that type and `isGoogleDriveMissingError`
  — so a plain `Error` aborts the entire selection snapshot, not one entry.
- `recordDriveChangeFailure` falls through to
  `if (!isGoogleDriveMissingError(error)) throw error;` and rethrows.
  `reconcileChangePages` persists the cursor only after the whole page loop
  completes, so the throw happens first and the cursor never advances.

Deterministic on every run that reaches the entry: the poison-page failure
this Lambda exists to prevent, relocated from Zod validation to shortcut
resolution, with no skippedEntries accounting and no recordSourceFailure.

- `resolveShortcut` now throws `GoogleDriveUnreadableFileError` with a
  `shortcutDetails.targetId` issue. Both call sites already classify that
  type: the enumeration path records a skipped entry (which correctly
  suppresses the unseen-source sweep, since a dangling shortcut says nothing
  about the target's existence), and the change path fails the one record via
  `recordSourceFailure` while the cursor advances.
- Guard-wiring test pinning the typed throw and the absence of a bare
  `Error`.
- Corrected the schema and test comments that claimed the "no target" path
  was already graceful — it was not, which is what made this reachable.

Refs PR #1621

Copy link
Copy Markdown
Member Author

🤖 pr-fix routine round 2 complete.

  • Review comments addressed: 1 of 1 actionable (the resolveShortcut finding from the round-1 re-review)
  • CI failures fixed: 0 (all 11 checks were green on 47ccea8)
  • Commits pushed: 1

The resolveShortcut finding is correct and I've fixed it in cde8217. Verified both halves of the claim against the source:

  • add() (index.ts) catches only GoogleDriveUnreadableFileError and rethrows everything else; the per-selection catch in enumerateInitialFiles classifies only that type and isGoogleDriveMissingError. A bare Error reaches throw error and takes down the whole selection snapshot.
  • recordDriveChangeFailure falls through to if (!isGoogleDriveMissingError(error)) throw error;, and reconcileChangePages persists the cursor only after the full page loop — so the throw lands first and the cursor never advances.

And it is genuinely reachable rather than theoretical: this PR is what loosened shortcutDetails.targetId from z.string() to optional, so a shortcut whose target was deleted now parses cleanly and then throws deterministically on every subsequent run.

Fix is the suggested one — resolveShortcut throws GoogleDriveUnreadableFileError with a shortcutDetails.targetId issue, so both call sites route it through paths they already have: a per-entry skip during enumeration, recordSourceFailure with an advancing cursor during change processing. Also corrected the schema comment and the test comment that both asserted the "no target" path was already graceful; that claim being wrong is what let this through.

One tradeoff I made rather than decided for you: the enumeration path counts this as a skipped entry, which suppresses markUnseenSourcesMissing for that run. For a permanently dangling shortcut that means retirement stays suppressed on that connector until the shortcut is removed. I took it deliberately — it is the existing conservative disposition for anything the client cannot resolve, and it fails toward stale content rather than deletion — but if you'd rather a dangling shortcut be a benign skip that doesn't block the sweep, that is a one-line change to make it not increment skippedEntryCount.

Round-1 verification caveat still applies: bun install is blocked by egress policy in this sandbox (403 on npm tarballs), so nothing was run locally. Round 1's changes were confirmed green by CI on 47ccea8Test, Lint and Type Check passed, along with all 11 checks. This round's commit needs the same confirmation before merge.


Generated by Claude Code

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review

Went through the full diff (drive-client.ts, index.ts, the new reconcile.ts/changes.ts, schema, and the three new test files). This is a well-scoped, carefully reasoned hardening PR — the invariant-driven approach (durable obligation before cursor advance, never-reinterpret-a-parse-failure-as-a-removal) is exactly the right way to close this class of bug, and the comments consistently explain why, not what.

Correctness — looks solid

  • reconcileChangePages correctly persists the cursor after every page unconditionally now, which is the actual fix for Gap 2 (previously cursor writes were suppressed once a page raised requiresSelectionSnapshot). The obligation-before-cursor ordering is verified directly in google-content-sync-reconcile.test.ts.
  • shouldRetireUnseenSources gating on skippedEntryCount === 0 before letting markUnseenSourcesMissing run is the right way to prevent a dropped/unreadable entry from being silently reinterpreted as "file no longer exists."
  • GoogleDriveUnreadableFileError is classified consistently at every call site (resolveShortcut, getFile, selection-root enumeration, change processing) — checked before the generic isGoogleDriveMissingError/rethrow branches in all three places I traced (enumerateInitialFiles's add, the selection-loop catch, recordDriveChangeFailure), so a parse failure can't escape and poison a page the way the original fileId-required schema did.
  • The drive-removal handling correctly scopes retirement to markUnseenSourcesMissing instead of rethrowing into markConnectorAccessLost for selectionKind === "drive" — avoids the connector-wide blast radius the old code had.
  • metadata is .default({}).notNull() in the schema, so the jsonb || merge in setSelectionSnapshotPending can't silently no-op against a NULL column.

Minor / worth a look

  • Envelope fields are still strict-parsed. filesListSchema/changesListSchema.parse() still validates nextPageToken/newStartPageToken (via optionalString, which requires an actual string or null/undefined — not e.g. a number) at the top level before per-entry parsing kicks in. If Google ever sends a malformed value for one of these (unlikely since they're server-generated tokens, but the whole point of this PR is not trusting that), the entire page still throws pre-cursor-advance — the exact "poison page" failure mode, just narrowed from "any entry" down to "one of two envelope fields." Probably fine to accept as residual risk given tokens are opaque server-generated strings, but worth confirming that's a conscious choice rather than an oversight, since it's not called out explicitly in the "Deliberately unchanged" section.
  • webViewLink/iconLink scheme check (/^https?:\/\//i) is a simple prefix test rather than a URL parse — fine for the stated goal (block javascript:/data:), just noting it's not a full URL validity check, so slightly malformed-but-http(s)-prefixed strings will still pass through to storage as-is.
  • Minor duplication between recordSkipped and recordUnreadable's logging blocks in enumerateInitialFiles (both log very similar shapes with different fields) — not worth abstracting given how small each is, just noting it.

Test coverage

Strong — per-entry skip/tolerance behavior, the cursor/obligation ordering invariants (including the crash-between-mark-and-persist and throw-during-snapshot cases), and the change-scope classification (isFileScopedDriveChange/isDriveRemovalChange) all have direct unit tests, plus the source-inspection tests in google-content-sync-change-scope.test.ts that pin the ordering/guard-placement invariants that unit tests alone can't easily express for index.ts (since index.ts itself isn't importable under the root jest config). No any types introduced, logging goes through log.* not console.*, consistent with repo conventions.

Security

No new injection surface — the href-scheme filtering on webViewLink/iconLink is a net improvement (explicit allowlist added where none existed before), and skipped-entry logging only includes extracted IDs and zod issue messages, not raw entry payloads, so it shouldn't leak arbitrary Drive content into logs.

Nice work — the "never reinterpret a parse failure as a removal" invariant and the durable-obligation-before-cursor-advance ordering are exactly the right fixes for the two failure classes described, and the reasoning is easy to audit thanks to the comments.

Copy link
Copy Markdown
Member Author

🤖 pr-fix routine round 3 — nothing further to change; marking pr-fix-done.

All 7 checks green on cde8217, including Test, Lint and Type Check. The round-2 re-review came back with no blocking findings and independently confirms both round-1 fixes — it traces GoogleDriveUnreadableFileError as now classified consistently at every call site including resolveShortcut, and confirms drive-removal retirement is scoped to markUnseenSourcesMissing rather than markConnectorAccessLost.

Resolved the two Codex threads (P1 and P2), both fixed and replied to.

What I did not change, and why

The round-2 review left three "minor / worth a look" notes. None are change requests, but one deserves your explicit call rather than my silent judgement:

Envelope fields are still strict-parsed. filesListSchema / changesListSchema validate nextPageToken / newStartPageToken through optionalString before per-entry parsing runs, so a non-string value in either field still throws the whole page pre-cursor-advance — the poison-page mode, narrowed from "any of 1000 entries" to "one of two envelope fields." The reviewer's read is that this is probably acceptable residual risk since both are opaque server-generated tokens, and asks whether it's deliberate.

I left it alone. It looks deliberate to me — you wrote rawEntriesSchema specifically for the entries and left the envelope strict — and it is a real narrowing of blast radius rather than an oversight. But I can't confirm your intent, and "make the envelope tolerant too" is a ~5-line change if you'd rather close the class completely. Worth one line in "Deliberately unchanged" either way.

The other two notes need no action: the webViewLink/iconLink scheme check being a prefix test rather than a full URL parse is correct for its stated goal (block javascript:/data:), and the recordSkipped/recordUnreadable logging similarity is too small to abstract.

Standing caveats for whoever merges

  1. Nothing was verified locally. bun install is blocked by egress policy in this routine's sandbox (403 on npm tarballs), so lint/typecheck/jest never ran here. Every claim above rests on CI, which is green on the current head.
  2. The PR description is stale in one spot. "Deliberately unchanged" still says enumerateInitialFiles' 403/404-on-selection-root behavior is "not widened here" — the P1 fix does widen it, for drive selections. One-line edit before merge.
  3. Open question from round 2, restated so it isn't lost: a permanently dangling shortcut now suppresses markUnseenSourcesMissing on that connector until the shortcut is removed. Deliberate (fails toward stale content, not deletion), reversible in one line if you disagree.
  4. This PR still stacks on fix: stop Shared Drive-scoped change entries from poisoning the Google content-sync queue #1617 — merge that first.

Removing pr-fix-done re-queues this PR for the routine.


Generated by Claude Code

@krishagel krishagel added the pr-fix-done pr-fix routine processed and PR is clean label Aug 8, 2026 — with Claude
@krishagel
krishagel merged commit 74f2010 into dev Aug 10, 2026
7 checks passed
@krishagel
krishagel deleted the fix/google-sync-hardening branch August 10, 2026 03:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-fix-done pr-fix routine processed and PR is clean

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants