You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
gmail.modify is declared but never requested: archive and mark-read can never work, and every Gmail account now reads "read-only — reconnect to allow sending" that reconnecting cannot clear #717
That same account reports "canSend": true on GET /v1/email/status. The console renders it as "read-only — reconnect to allow sending" — false on both halves: it is not read-only, and what it lacks is not send.
gmail_archive and gmail_mark_read cannot succeed for anybody, including a fresh subscriber to the Inbox Chat agent seeded alongside them (migration 0136). They refuse with a remedy that does not exist:
"Reconnect it in the console (Preferences → Connections) and allow the manage-mail permission"
There is no manage-mail permission to allow. Google never offers it, because PAGS never asks for it.
Mechanism — two correct-looking decisions, one live gap
c7c1adae (#716) added gmail.modify to the connector manifest:
workers/api/src/lib/connectors/gmail.ts:465
"https://www.googleapis.com/auth/gmail.modify",
But Gmail does not connect through the generic connector OAuth route. routes/connectors.ts:81 pins it to a dedicated flow that predates the registry:
git show c7c1adae -- workers/api/src/routes/email.ts is empty. The manifest and the live authorize URL are two different lists, and only one of them moved.
The generic route /v1/connectors/gmail/oauth/start does build the URL from the manifest (routes/connectors.ts:258), so it would carry gmail.modify — but its redirect_uri is /v1/connectors/gmail/oauth/callback, which is not registered on the Google client, so it fails at the provider. lib/connectors/connected-accounts.ts:38-43 says so explicitly, and the console never links it. There is no reachable path to a gmail.modify grant today.
Then the shortfall report reads the manifest, not the flow:
routes/connectors.ts:147 — missingScopesFor(connector, grantedScopes) diffs connector.oauth.scopes against what was granted. gmail.modify is in the former, can never be in the latter, so it is permanently missing.
store/console/src/lib/accountConnections.ts:68 and :136 then render any non-empty missingScopes with one hardcoded sentence:
? "read-only — reconnect to allow sending"
The note is binary; the shortfall is not. So an account missing only modify is described as read-only.
routes/connectors.ts:110-118 still carries the post-mortem of the last one:
"every Gmail connection reported email permanently missing, so every account rendered as 'read-only — reconnect to allow sending' no matter what the owner had just granted … The reconnect it asked for could never clear it, because the mismatch is in the comparison rather than in the grant."
Same symptom, same unclearable remedy, different cause: last time the comparison was wrong, this time the ask is wrong.
It also breaks the acceptance criterion #714 shipped under, verbatim:
"every refusal the tools can produce corresponds to something the owner can see and change in the console, without reading a chat transcript to discover it"
RECONNECT_TO_MODIFY (lib/connectors/gmail.ts:241) is a refusal that corresponds to nothing the owner can change anywhere.
Why no test caught it
lib/connectors/gmail.test.ts:207 asserts the manifest contains gmail.modify. It does. Nothing asserts the live authorize URL does.
lib/tool-reachability.test.ts already has the exact vocabulary for this state — sheets_read: "inert until the Sheets OAuth scope lands on the Google client; until then every call fails at consent, not at the tool."gmail_archive / gmail_mark_read are in that state, but the guard measures declaration, not grantability, so they pass.
What to do — cheapest first
1. One line, restores the feature (ship this first). Add the scope to the live flow:
Note what this costs and decide it knowingly: gmail.modify then appears in every new Gmail consent, including for owners who only want reading. That is a restricted scope on a CASA-assessed app. If the answer is "not by default", the one-line fix is instead to remove gmail.modify from the manifest and mark the two tools unreachable-by-design in tool-reachability.test.ts until the elevate path exists — which un-breaks the status line and the false refusal in the same commit, at the cost of the archive feature. Either is coherent; shipping neither is not.
2. Make the shortfall note say what is actually missing.accountRows() and connectionSummary() should branch on WHICH scopes are absent, not on length > 0:
Missing
Note
gmail.send
"read-only — reconnect to allow sending"
gmail.modify only
"cannot archive or mark read — reconnect to allow managing mail"
both
"read-only — reconnect to allow sending and managing mail"
This is worth doing even if (1) lands, because after (1) a user who declines the manage-mail box at Google lands in exactly this state legitimately, and would be told the wrong thing.
3. Guard it. A test that builds the authorize URL /v1/email/google/start returns and asserts its scope param is a superset of GMAIL_MANIFEST.auth.scopes. Same for /v1/drive/google/start vs GOOGLE_DRIVE_CONNECTOR.oauth.scopes — the class is "a dedicated flow and its manifest drift", not "Gmail". Cheap, and it is the only thing that stops the next scope addition repeating this.
4. Correct the published doc, which currently states something untrue. platform-docs/connectors.md:405 says "Two OAuth scopes are requested" above a three-row table, and :418-420 says:
"Google's consent screen lets a person grant reading and sending but decline manage-mail"
It does not — the checkbox is never shown. Replace with whatever (1) settles.
Suppress gmail.modify from missingScopesFor as "optional" without touching the flow. Rejected on its own: it silences the false status line but leaves the two tools permanently refusing with a remedy that does not exist. It becomes the right answer only as part of the baseline/optional split proposed separately.
Point the console's Connect button at the generic route so it picks up the manifest list. Rejected: redirect_uri is unregistered on the Google client, so it fails at Google — the exact caveat documented at connected-accounts.ts:38.
Acceptance criteria
GET /v1/connectors no longer reports a scope PAGS never asks for. Either the flow asks for it, or the manifest stops declaring it.
An account that granted send does not render as "read-only".
Either gmail_archive succeeds after a reconnect, or it is not declared on a published catalog agent.
A test fails if a scope is added to GMAIL_MANIFEST.auth.scopes without the live start route requesting it.
platform-docs/connectors.md describes the consent screen a user actually sees.
Regression risk
Adding modify re-prompts every connected user (prompt=consent is already set at email.ts:70, so a reconnect genuinely re-asks). Anyone who reconnects and unticks manage-mail lands in the state fix (2) exists to describe — so (1) without (2) trades a universal wrong message for an occasional one.
Removing modify from the manifest instead would flip GMAIL_CONNECTOR.scopes.write? No — write is derived from tool scope, and gmail_reply/gmail_send keep it true. lib/connectors/gmail.test.ts:220 asserts that and would catch it if wrong.
Measured, not inferred
Production GET /v1/connectors and GET /v1/email/status, operator account, 2026-08-21: quoted above verbatim.
git show c7c1adae -- workers/api/src/routes/email.ts → empty diff.
git grep -c include_granted_scopes over the repo → 0 matches (relevant to the follow-up, not to this fix).
Not reproduced: an actual gmail_archive call. Doing so would need the owner's agent driven live; the refusal path is read from lib/connectors/gmail.ts:416 and canModify → scopesAllowModify (lib/gmail.ts:694), which returns false for any grant string without gmail.modify or https://mail.google.com/, and neither is obtainable.
Related: #716 (the commit trailer for this work cites #716, which is a different issue — subscribe idempotency; the Gmail-modify work has no issue of its own), #715, #714, #713, #352.
What the owner sees
Two things, both wrong, both live in production right now (measured 2026-08-21, operator account,
GET /v1/connectors):{ "accountId": "sivochkin@gmail.com", "connectedAt": "2026-08-20 20:39:54", "missingScopes": ["https://www.googleapis.com/auth/gmail.modify"] }That same account reports
"canSend": trueonGET /v1/email/status. The console renders it as "read-only — reconnect to allow sending" — false on both halves: it is not read-only, and what it lacks is not send.gmail_archiveandgmail_mark_readcannot succeed for anybody, including a fresh subscriber to the Inbox Chat agent seeded alongside them (migration0136). They refuse with a remedy that does not exist:There is no manage-mail permission to allow. Google never offers it, because PAGS never asks for it.
Mechanism — two correct-looking decisions, one live gap
c7c1adae(#716) addedgmail.modifyto the connector manifest:workers/api/src/lib/connectors/gmail.ts:465But Gmail does not connect through the generic connector OAuth route.
routes/connectors.ts:81pins it to a dedicated flow that predates the registry:and that route's authorize URL has not changed since #713 —
workers/api/src/routes/email.ts:65git show c7c1adae -- workers/api/src/routes/email.tsis empty. The manifest and the live authorize URL are two different lists, and only one of them moved.The generic route
/v1/connectors/gmail/oauth/startdoes build the URL from the manifest (routes/connectors.ts:258), so it would carrygmail.modify— but itsredirect_uriis/v1/connectors/gmail/oauth/callback, which is not registered on the Google client, so it fails at the provider.lib/connectors/connected-accounts.ts:38-43says so explicitly, and the console never links it. There is no reachable path to agmail.modifygrant today.Then the shortfall report reads the manifest, not the flow:
routes/connectors.ts:147—missingScopesFor(connector, grantedScopes)diffsconnector.oauth.scopesagainst what was granted.gmail.modifyis in the former, can never be in the latter, so it is permanently missing.store/console/src/lib/accountConnections.ts:68and:136then render any non-emptymissingScopeswith one hardcoded sentence:? "read-only — reconnect to allow sending"The note is binary; the shortfall is not. So an account missing only
modifyis described as read-only.Why this is the #715 bug in a new costume
routes/connectors.ts:110-118still carries the post-mortem of the last one:Same symptom, same unclearable remedy, different cause: last time the comparison was wrong, this time the ask is wrong.
It also breaks the acceptance criterion #714 shipped under, verbatim:
RECONNECT_TO_MODIFY(lib/connectors/gmail.ts:241) is a refusal that corresponds to nothing the owner can change anywhere.Why no test caught it
lib/connectors/gmail.test.ts:207asserts the manifest containsgmail.modify. It does. Nothing asserts the live authorize URL does.lib/tool-reachability.test.tsalready has the exact vocabulary for this state —sheets_read: "inert until the Sheets OAuth scope lands on the Google client; until then every call fails at consent, not at the tool."gmail_archive/gmail_mark_readare in that state, but the guard measures declaration, not grantability, so they pass.What to do — cheapest first
1. One line, restores the feature (ship this first). Add the scope to the live flow:
Note what this costs and decide it knowingly:
gmail.modifythen appears in every new Gmail consent, including for owners who only want reading. That is a restricted scope on a CASA-assessed app. If the answer is "not by default", the one-line fix is instead to removegmail.modifyfrom the manifest and mark the two tools unreachable-by-design intool-reachability.test.tsuntil the elevate path exists — which un-breaks the status line and the false refusal in the same commit, at the cost of the archive feature. Either is coherent; shipping neither is not.2. Make the shortfall note say what is actually missing.
accountRows()andconnectionSummary()should branch on WHICH scopes are absent, not onlength > 0:gmail.sendgmail.modifyonlyThis is worth doing even if (1) lands, because after (1) a user who declines the manage-mail box at Google lands in exactly this state legitimately, and would be told the wrong thing.
3. Guard it. A test that builds the authorize URL
/v1/email/google/startreturns and asserts itsscopeparam is a superset ofGMAIL_MANIFEST.auth.scopes. Same for/v1/drive/google/startvsGOOGLE_DRIVE_CONNECTOR.oauth.scopes— the class is "a dedicated flow and its manifest drift", not "Gmail". Cheap, and it is the only thing that stops the next scope addition repeating this.4. Correct the published doc, which currently states something untrue.
platform-docs/connectors.md:405says "Two OAuth scopes are requested" above a three-row table, and:418-420says:It does not — the checkbox is never shown. Replace with whatever (1) settles.
Alternatives considered and rejected
DEDICATED_FLOWS), which would make the manifest the single list and fix this structurally. Rejected as the fix: it is a design issue with no owner and a Google-console step, and until it lands a seeded catalog agent ships a capability nobody can use. Do (1)–(3) now; [design] Stage 2: register the generic connector OAuth redirect for Drive/WorkDrive/Gmail and retire the dedicated flows (Stage 1 — the registry declaration — has shipped) #352 later deletesDEDICATED_FLOWS.gmailand (3) keeps guarding.gmail.modifyfrommissingScopesForas "optional" without touching the flow. Rejected on its own: it silences the false status line but leaves the two tools permanently refusing with a remedy that does not exist. It becomes the right answer only as part of the baseline/optional split proposed separately.redirect_uriis unregistered on the Google client, so it fails at Google — the exact caveat documented atconnected-accounts.ts:38.Acceptance criteria
GET /v1/connectorsno longer reports a scope PAGS never asks for. Either the flow asks for it, or the manifest stops declaring it.gmail_archivesucceeds after a reconnect, or it is not declared on a published catalog agent.GMAIL_MANIFEST.auth.scopeswithout the live start route requesting it.platform-docs/connectors.mddescribes the consent screen a user actually sees.Regression risk
modifyre-prompts every connected user (prompt=consentis already set atemail.ts:70, so a reconnect genuinely re-asks). Anyone who reconnects and unticks manage-mail lands in the state fix (2) exists to describe — so (1) without (2) trades a universal wrong message for an occasional one.accountConnections.ts, whichaccountConnections.test.tscovers;needsReconnect's multi-account short-circuit (:109) must keep behaving as Multiple accounts per connector — one Gmail per user is a primary key, and GitHub already does it right #715 left it — a two-account owner already sees per-account notes and no summary note.modifyfrom the manifest instead would flipGMAIL_CONNECTOR.scopes.write? No — write is derived from toolscope, andgmail_reply/gmail_sendkeep ittrue.lib/connectors/gmail.test.ts:220asserts that and would catch it if wrong.Measured, not inferred
GET /v1/connectorsandGET /v1/email/status, operator account, 2026-08-21: quoted above verbatim.git show c7c1adae -- workers/api/src/routes/email.ts→ empty diff.git grep -c include_granted_scopesover the repo → 0 matches (relevant to the follow-up, not to this fix).gmail_archivecall. Doing so would need the owner's agent driven live; the refusal path is read fromlib/connectors/gmail.ts:416andcanModify→scopesAllowModify(lib/gmail.ts:694), which returns false for any grant string withoutgmail.modifyorhttps://mail.google.com/, and neither is obtainable.Related: #716 (the commit trailer for this work cites #716, which is a different issue — subscribe idempotency; the Gmail-modify work has no issue of its own), #715, #714, #713, #352.