0099 — Many Google accounts, each with read or write access
Status: accepted · 2026-10-06
- Status: accepted
- Date: 2026-10-06
Context
Google was connected, but not manageable. ADR 0037 brought accounts to Conch, ADR 0040 guided the Google Cloud setup and ADR 0048 made Gmail, Calendar and Drive ordinary apps with an app-password path for Gmail. Four things were still wrong for the person using it:
- One account at a time, in practice. The store kept several, but the only list was a stack of buttons inside a sign-in view ("Use this account", "Reconnect for this job"). Nothing said which account served which app.
- Read-only without saying so. Every capability was a read, except Gmail's draft. An app password could actually do everything in Gmail over IMAP; Conch simply never sent. A person who wanted their assistant to answer an email, move a meeting or write a document could not say so anywhere.
- No way to ask for more. "Capabilities" were decided by whichever job started a sign-in. There was no place to see what Google had allowed, let alone to raise it.
- Unstyled chrome. The credential file arrived through a bare
<input type="file">and a password field labelled "Or paste credential JSON", inside a four-step console guide, inside a connect dialog.
Decisions
Access is a level per product per account
A product is gmail, calendar or drive; a level is off, read or
write (@conch/protocol GoogleProduct, GoogleAccess, GoogleLevel).
Write includes read. The capabilities a tool asks for are derived from levels
(capabilitiesFor, accessOf), so one list of scopes stays the source of
truth and no caller invents a scope.
Each account now carries two maps: granted, the most its sign-in allows, and
access, what the person chose, held under granted (accountsOf in
google/service.ts). capabilities is access expanded, so every existing
reader — the apps, the prompt, routines, the model's google_accounts — sees
exactly what Conch may do and nothing wider. The person's choice lives in
limits in the sealed store, beside the credential it bounds.
setAccess refuses a level above granted with consent, which the web app
turns into one Google sign-in for that account, asking for everything it
already has plus the one new thing (Google does not support incremental
authorization for installed apps, ADR 0040). Lowering never needs Google.
Raising within granted still needs a recently verified session, like every
other grant of reach; lowering does not.
Writes exist, and every one asks
Gmail can send, Calendar can add, change and delete an event, and Drive can
make a file (google/writes.ts). Scopes stay the narrowest that do the job:
gmail.compose, calendar.events, and drive.file beside
drive.metadata.readonly — Conch can only touch files it made itself.
Every write asks the person, every time, with the account and exactly what
will happen, whatever the app's policy says (alwaysAsks; the server refuses
allow for them). The account's scope identity is read before the question and
again after it: a sign-in, a revocation or a level the person lowered while the
card waited stops the write, and nothing is done. SATISFIED_BY lets a broader
scope Google already granted satisfy a job, so nobody is asked twice.
service.api no longer allows any POST that isn't Gmail's draft: each write is
one row of WRITES with the single capability it needs, matched on method and
path. A draft token cannot send, and a path that matches no row is refused
before any credential is read.
A write that might have happened is found, never repeated
Each write derives its own identity from the durable operation id, so looking for it is possible and repeating it is not:
- A calendar event is created with
id = eventIdFor(operationId), so Google itself rejects a second attempt (409 is read as "mine, already there"). - A sent email carries
Message-ID: <conch.…@sender's domain>; it is found byrfc822msgidin Sent, or over IMAP in All Mail for an app-password account. - A Drive file carries
appProperties.conchOperation. - A change or a deletion is idempotent in Google's own terms: reconciliation
reports
absentonly when applying it again cannot do harm (the same fields, the same deletion), andunknownotherwise.
A 4xx from Google is not-executed — nothing happened. A transport failure,
a 5xx or an unreadable body on a write is ambiguous and is never retried
(ADR 0037's rule), with words that say to look before trying again.
Sending with an app password goes over SMTP to smtp.gmail.com with TLS only
(GmailImap.send, the pretend mail service on 127.0.0.1 in tests). Failures
before the message is handed over are "nothing was sent"; after, SendUncertain
and Sent is the only proof.
Accounts, as a list a person can manage
Nacre gained two patterns:
AccountAccessCard/AccessLevels(patterns/Integrations/): one card per account — who, how it's signed in, its state in words — then a row per product with Off · Read · Read & write, what the current level means under it, and the problem with its one fix first when something needs the person. A product an account cannot reach (Calendar on an app password) says why and offers the button that changes that, never a control that would pretend.FileDropZone(patterns/Attachments/): one file dropped, chosen or pasted, for a credential a person downloaded somewhere else. The unstyled file input and the "Or paste credential JSON" field are gone.
The web app has one "Google accounts" section, the same on all three app pages
(GoogleAccounts.tsx), with the current app's row marked. Adding an account is
a guided flow: what it should help with, then the simplest way to connect for
that — an app password when it's Gmail alone and there is no Google app yet,
Google sign-in otherwise — with the trade-off in one line, then connecting.
A Google sign-in for an address that was connected with an app password
replaces it, so there is one entry per address.
What the model gets
google_accounts lists each account's email, state and level per product, and
every tool takes accountId as an email (or an id). Left out, the one account
that can do the job is used; with several, the tool names them and asks for a
choice; with none, it says the person can allow it in Apps. A tool whose
capability the person didn't grant is not offered at all, and a call that
reaches further is refused with words a model can act on, never by trying.
Security
- The person's levels live in
google.secrets.json, beside the credential they bound, under the same device sealer, protected paths and secret-only backup rule. Nothing new is written anywhere else. The browser never learns a token, an app password or a client secret. - An account's access travels only with its own sealed credential: a backup preview cannot open sealed files, so a restored account comes back with the very sign-in and the very levels it had, and a backup cannot grant more than it carried.
- A raise is a human action in the UI with a verified session; the agent has no
tool that can change a level, and
setAccessis the only way one moves. - Writes bind account, arguments and scope identity, and are approved one at a
time (
once), with taint and skill restrictions on the same card. - Addresses are validated emails, subjects cannot hold a line break, bodies are
base64, reply ids are checked tokens: no header can be injected into a draft
or a sent message.
multipart/relatedfor Drive uses a random boundary and text parts only. - Fixed Google hosts only, no redirects, bounded bodies, 20-second timeouts.
Migration
A store written before this (no version) is brought up to date as it is read
(migrateGoogle): every account keeps exactly what it could do — Gmail that
could save drafts becomes Gmail's write level — and google_mail_send, which
did not exist, starts off in Gmail's tools, so no older setup can newly reach
out before a person turns it on.
Verification
google/access.test.ts covers levels under grants, the consent refusal, the
app-password ceiling, replacing a password account with a sign-in, the
migration, picking an account among several, and each write: one question, the
exact request, the stopped write when access changes mid-approval, the event
found instead of made twice, and sending over the pretend SMTP service. The web
and Nacre tests cover the accounts list, the raise flow, the drop zone and the
guided add. No real Google account, email or network is part of any of it; a
real account's consent and API access remain external acceptance steps.
This extends ADR 0037, ADR 0040 and ADR 0048 — their credential, approval and durable-effect contracts hold unchanged.