From ff478a33426d2ff0372c6d8f44b9b220288b6cc5 Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:55:53 -0700 Subject: [PATCH 01/11] docs: calendar authorization contract Decide the nine authorization questions T12 (Google Calendar) depends on: OAuth scopes, Google-account to Buzz-identity binding, calendar ownership and sharing, per-channel visibility, disconnect, cached data on disconnect and on membership loss, revocation timing, refresh-token failure UX, and agent access. Google stays authoritative for calendar access; Buzz makes no access decision and renders each account's own API answer. Agents get no calendar credential in v1; the sanctioned later shape is RFC #3227's separate scoped principal. Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/plans/2026-09-04-calendar-authorization.md diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md new file mode 100644 index 00000000000..9c639f1e7b7 --- /dev/null +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -0,0 +1,245 @@ +# Calendar authorization contract (T11) + +Date: 2026-09-04. Owner ticket: T11 `docs/calendar-authz` in +[`2026-09-04-zs-implementation-plan.md`](2026-09-04-zs-implementation-plan.md). This memo is the +authorization contract that T12 (`feat/google-calendar`) implements and that T12a's view design +assumes. It decides nine questions; each decision below states what we do and why. + +## Summary + +Buzz shows the business calendar that already lives in Google Workspace. It does not own the +calendar, does not grant access to it, and does not reconcile Google's ACL with Buzz channel +membership. Each signed-in human connects their own Google account through an installed-app +OAuth flow; the desktop holds that grant in the OS keychain and renders exactly what that +account's own API calls return. Buzz membership decides where the surface appears; Google +decides what it contains. Agents get no calendar credential in v1. + +This is the shape upstream RFC #3227 argues for: an outside service reaches Buzz as a +*scoped integration* with a narrow credential that never enters the agent sandbox, not as a +platform primitive. `VISION.md:9` argues against stitching outside services into the workspace; +the scoped-integration shape is what keeps that argument intact — the calendar is a view onto +someone else's system, not a Buzz data model. + +## Why the two obvious alternatives are out + +- **A native shared-calendar event kind.** Idiomatic Buzz (`AGENTS.md` "Prefer Nostr events over + new HTTP endpoints"), but the relay rejects unknown kinds: `required_scope_for_kind` in + `crates/buzz-relay/src/handlers/ingest.rs:437` returns `Err` for any kind it does not know, and + we do not operate the hosted relay's allow-list. A native kind is an upstream-first ticket, not + this fork's v1. +- **An embedded Google Calendar iframe.** The desktop CSP is `default-src 'self'` with no + `frame-src` (`desktop/src-tauri/tauri.conf.json:39`), and Google Calendar refuses framing. The + view has to be native, which means the app holds a credential, which is why this memo exists. + +## Decisions + +### 1. OAuth scopes requested + +**Decision.** Installed-app authorization-code flow with PKCE (S256) and a loopback redirect on +`http://127.0.0.1:/oauth/callback`, opened in the system browser. Two stages: + +- At connect: `openid`, `https://www.googleapis.com/auth/userinfo.email`, + `https://www.googleapis.com/auth/calendar.calendarlist.readonly`, + `https://www.googleapis.com/auth/calendar.events.readonly`. +- At the first edit attempt, by incremental authorization (`include_granted_scopes=true`): + `https://www.googleapis.com/auth/calendar.events`. + +Never requested: `https://www.googleapis.com/auth/calendar` (full calendar management) and +`https://www.googleapis.com/auth/calendar.acl`. The granted scopes are read back from the token +response and persisted; a user who unchecks a box gets the read-only surface, not a broken write +path. + +**Reason.** `calendarlist.readonly` is the only way to resolve the business calendar's id and the +caller's `accessRole` without asking for calendar management. Splitting read from write means a +member who only ever looks at the calendar never holds a token that can change it. Reading the +granted scopes back rather than assuming them is what makes the read-only fallback real: Google's +consent screen lets the user drop a scope, and an app that assumes it got what it asked for fails +at write time with a raw 403. + +### 2. Which Google account binds to which Buzz identity, and how the binding is stored + +**Decision.** One Google account per Buzz identity per installation. The Buzz side of the binding +is the active identity's pubkey hex (`get_identity`, `desktop/src-tauri/src/commands/identity.rs`); +the Google side is the OIDC `sub`, not the email. The record — refresh token, access token, +expiry, granted scopes, `sub`, email for display — is stored in the OS keychain through +`SecretStore` under a key namespaced by pubkey hex. `SecretStore` keeps all secrets as one JSON +blob (service `buzz-desktop`, username `secrets`, `desktop/src-tauri/src/secret_store.rs:1-21`), +so this costs no extra keychain prompt. Token exchange, refresh and every Google API call happen +in Rust. The webview receives a redacted status struct only: connected, email, granted scopes, +expiry, state. Connecting a different Google account requires an explicit confirm and revokes the +previous grant first (decision 5). The pubkey → Google-account mapping is never published to the +relay. + +**Reason.** `sub` is stable; a Workspace email can be renamed or reassigned to a different human, +and a binding keyed on email would silently follow the address to the new person. Keeping tokens +out of the webview matters because the CSP's `connect-src` already allows `https:` — a token in +the renderer is one XSS away from any host. Not publishing the mapping keeps the workspace +identity graph off the relay, where the operator and every channel member would otherwise see who +holds which Google account. + +### 3. Who owns the business calendar, and how sharing is granted + +**Decision.** The business calendar is a Google Workspace secondary calendar owned by the +Workspace (an admin-held account or a resource account), not by any individual's primary calendar. +All sharing is granted in Google Calendar's own ACL — "Make changes to events" for staff who +schedule, "See all event details" for everyone else. Buzz never creates, grants, changes or +revokes a calendar ACL, and holds no scope that would let it. Edit affordances in the UI derive +from the `accessRole` that `calendarList` returns for that user (`owner`/`writer` enable edit; +`reader`/`freeBusyReader` disable it), and a write Google rejects surfaces as a failure — never as +a local "saved" state. + +**Reason.** Two access-control systems that can disagree is the failure mode the feature audit +named (`2026-09-04-zs-feature-audit.md:57`). Buzz's only gate is channel membership +(`VISION.md:37`); Google's is the calendar ACL. Reconciling them means one of them is a stale copy +of the other, and the copy will be wrong the day someone is offboarded. Instead Buzz makes no +access decision at all: it renders each account's own answer. Removing someone from the business +means removing them in Google Workspace, which is the same sentence the audit already wrote. +Owning the calendar with the Workspace rather than a person means an offboarded owner does not +take the calendar with them. + +### 4. Which channels show the calendar, and who chooses + +**Decision.** The calendar surface is opt-in per channel. A channel admin (kind:39001, with +membership at kind:39002 — `crates/buzz-core/src/kind.rs:424-426`) chooses which calendar id a +channel maps to. For v1 that mapping is a local per-identity desktop setting in the app-data dir +next to the archive DB; a relay-synced mapping needs a new event kind and is deferred to an +upstream-first ticket (see "Why the two obvious alternatives are out"). The mapping is a +*display* choice and carries no authority: a channel member whose Google account is not on the +calendar's ACL sees an empty surface with "not shared with your account", never someone else's +events. + +**Reason.** Making the mapping powerless is what lets it be stored loosely. If a wrong or stale +mapping could expose event data, it would need the same durability and audit as the ACL itself; +because every read is made with the viewer's own token, the worst outcome of a bad mapping is an +empty panel in the wrong channel. That trade buys v1 out of a relay change we cannot make. + +### 5. Disconnect behavior + +**Decision.** "Disconnect" in Buzz is one user action with an ordered, resumable effect: + +1. Write the binding record to `revoke_pending` (one persist, not three). +2. Call Google's revocation endpoint with the refresh token. +3. Delete the keychain record (`SecretStore::delete`), drop the in-memory token cache, and fence + any in-flight refresh by generation so a late response cannot rewrite a deleted record. +4. Purge the cached events for that binding (decision 6). +5. Leave the channel mapping in place, so reconnecting returns to the same view. + +If step 2 fails (offline, 5xx), steps 3–5 still run and the `revoke_pending` record survives as a +durable retry journal, retried on next launch until Google confirms; it is never dropped on a +caught error. Full sign-out already covers both halves: the boot reset renames the app-data dir +and calls `delete_all_with_legacy()` then `verify_fully_wiped()` on the keychain +(`desktop/src-tauri/src/reset.rs:273,315`). + +**Reason.** `AGENTS.md` Review-Proven Rule 1 — a caught failure leaves a durable retry record or +propagates; deleting the journal before the retry succeeds is exactly the PR #6269 defect. Rule 5 +— one user action is one atomic persist, ordered so every prefix is consistent: a crash after step +3 leaves a revoked-or-pending grant and no local token, which is safe. Rule 2 — the generation +fence stops a completing refresh from resurrecting a deleted binding. + +### 6. Cached event data on disconnect and on membership loss + +**Decision.** Cached events are a bounded render cache: per binding, keyed by Google `sub` and +calendar id, in its own SQLite file in the nest — not mixed into the relay archive tables, whose +rows carry a relay access proof that calendar rows do not have +(`desktop/src-tauri/src/archive/mod.rs:1-19`). The cache holds only the expansion window the view +needs (window and recurrence expansion are T12a's to size) under a hard row cap, and every row +carries the timestamp of the refresh that produced it. + +- **On disconnect:** purged, as part of decision 5. +- **On loss of Buzz channel membership:** the channel's mapping row is dropped and the surface + disappears from that channel; the event cache is *not* purged. The user's Google access did not + change. +- **On loss of Google access** (403/404 on the calendar, or it stops appearing in + `calendarList`): the calendar's cached rows are purged on that response and the surface shows + "no longer shared with your account". +- **When we cannot tell** (network failure, refresh failing): the last-good view is shown marked + stale with its refresh time, edits are disabled, and at a 24-hour staleness ceiling the events + are dropped and the surface asks for a reconnect. + +**Reason.** Purging on Buzz membership loss would mean Buzz is enforcing Google's ACL, which +decision 3 refuses; the user still has the calendar in Google, and their local copy is theirs. The +staleness ceiling is the other half of that: an unbounded offline cache would keep showing a +calendar the user may have lost, with no bound on how long. Disabling edits from stale state stops +a write built on data we already know may be wrong. Keeping the cache out of the archive DB keeps +its access-proof invariant honest. + +### 7. Revocation propagation timing + +**Decision.** Propagation is poll-bound, and the bound is stated rather than promised as instant. +Access tokens are short-lived (about an hour); the surface refreshes on channel focus and, while +visible, on a bounded poll with backoff — target at most five minutes between refreshes. A +principal removed in Google Workspace loses the view at the first API call after Google applies +the change: worst case one poll interval while the surface is open, or at next open. The 24-hour +staleness ceiling from decision 6 is the outer bound for an app that cannot reach Google at all. +Google push notifications are not used in v1: they need a public HTTPS callback, and the relay's +HTTP surface is deliberately narrow (`AGENTS.md` "Nostr-first HTTP surface"). A revocation — by an +admin, or by the user at their Google account page — invalidates the refresh token, and the next +refresh returns `invalid_grant`, which is decision 8. + +**Reason.** The honest statement of a poll-based system is its interval, not "immediately". Naming +the worst case makes it reviewable; a webhook would shorten it but costs a public endpoint the +fork does not want, so the trade is written down instead of hidden. The backoff and the terminal +state are Rule 4: a persistent failure must not amplify into an unbounded refresh loop. + +### 8. Refresh-token failure UX + +**Decision.** Two classes, handled differently. + +- **Transient** (network, 5xx, 429): exponential backoff with a cap and a terminal state, no + prompt. The view stays visible marked stale (decision 6) until the staleness ceiling. +- **Terminal** (`invalid_grant`, `invalid_client`, revoked or expired grant, a required scope no + longer granted): the refresh loop stops at once, the binding moves to `needs_reconnect`, event + data is dropped at the ceiling, and a quiet, persistent "Reconnect Google Calendar" action + appears on the calendar surface *and* in settings. + +The reconnect action is never hidden behind the same state it repairs, and the failure never signs +the user out of Buzz, never deletes the channel mapping, and never shows a raw OAuth error string. +Failures are logged with the reason and never with a token or an authorization code; T12 asserts +that with a test. Reconnect reuses the connect flow and keeps the binding when `sub` matches; a +different `sub` is an account change and takes the explicit confirm from decision 2. + +**Reason.** Rule 6 — a guard that hides the only recovery affordance is a functional failure, so +the reconnect entry lives in two places, one of which does not depend on the broken surface +rendering. Rule 4 — a terminal auth error must stop the loop, not retry forever against a grant +that will never come back. Splitting transient from terminal is what stops a flaky network from +nagging the user to re-consent. + +### 9. What an agent may read or write + +**Decision.** Nothing, in v1. No managed agent, ACP harness or MCP server receives a Google +credential, and the desktop registers no calendar command on any agent-facing surface. No calendar +key is ever written into a harness environment; the identity keys already reserved from user +override (`desktop/src-tauri/src/managed_agents/reserved_env_keys.rs`) are the precedent, and a +Google refresh token is the same class of secret with a larger blast radius. T12 binds this with a +falsifiable test: the agent-facing command list and the spawn environment contain no calendar +entry, so deleting the guard fails a test. + +The sanctioned way to give an agent calendar access later is RFC #3227's shape, and it is a +separate ticket: a *distinct Google principal* — a dedicated Workspace account or service account +with its own row in the calendar's ACL — delivered as a scoped credential that is substituted at +egress, never present in the owner-review draft and never inside the agent sandbox. Humans keep +their own grants; the agent gets its own, revocable on its own. + +**Reason.** A per-human OAuth grant handed to an agent lets the agent act as that human inside +Google, outside Buzz's channel gate and outside anything a Workspace admin would expect to see in +an audit log. It also breaks the one-way property this contract rests on: Buzz makes no access +decisions. An agent with its own principal keeps Google authoritative — the admin can see it, +scope it, and remove it in the same place they manage everyone else. + +## Deferred, with the ticket that owns it + +- Event model, expansion window, month and agenda rendering, keyboard and screen-reader + semantics — T12a `docs/calendar-view-design`. +- Implementation of this contract, the mock Google server and the live two-account checklist — + T12 `feat/google-calendar`. +- Relay-synced channel → calendar mapping (needs a new allow-listed kind) — upstream-first, + not scheduled. +- Agent calendar access through a separate Google principal — blocked on RFC #3227 landing + upstream. + +## Relates to + +- Upstream RFC #3227 — app-integration agents with scoped credentials (the shape this follows). +- Upstream PR #1382 — the closed Google Calendar work T12 revives for the OAuth and storage half. +- `2026-09-04-zs-feature-audit.md` §4 — the audit that ruled out a native kind, Cal.com and + iframes. From 77a7a5f10115cd7e04ec527010e89657752aaa8d Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:05:13 -0700 Subject: [PATCH 02/11] docs: decide OAuth client, v1 mapping authority, cross-device disconnect Fixes the three WARN findings from the T11 critic pass on the calendar authorization contract. - Decision 4 stated an authority v1 cannot implement: an admin "chooses" the channel-to-calendar mapping while the mapping is a local per-identity setting with no relay-synced kind. Say plainly that in v1 each user sets the mapping locally, the admin's choice travels out of band as a convention, and admin-owned mapping arrives with the relay-synced kind. Names the half T12 can bind to a test. - Decision 1 now decides the OAuth client itself: the Cloud project that owns it, Desktop-app type, Internal consent screen, and In production rather than Testing publishing status. A Testing client expires refresh tokens after seven days, which would make decision 8's terminal invalid_grant a weekly prompt. Also states that the installed-app client secret is not confidential. - Decision 5 now states that Google revokes per client and account, not per device, so Disconnect ends the grant on the user's other Buzz installations; the confirm says so and the other install's reconnect state names the cause. - NITs: the Summary no longer borrows RFC #3227 for decisions 1-8, which it does not cover; the secret_store.rs cite points the service-name constant at line 50. Gates: just fmt-check file-size-check green. Docs-only diff, no cargo filter applies. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 83 ++++++++++++++----- 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index 9c639f1e7b7..eeeb18bcf87 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -14,11 +14,13 @@ OAuth flow; the desktop holds that grant in the OS keychain and renders exactly account's own API calls return. Buzz membership decides where the surface appears; Google decides what it contains. Agents get no calendar credential in v1. -This is the shape upstream RFC #3227 argues for: an outside service reaches Buzz as a -*scoped integration* with a narrow credential that never enters the agent sandbox, not as a -platform primitive. `VISION.md:9` argues against stitching outside services into the workspace; -the scoped-integration shape is what keeps that argument intact — the calendar is a view onto -someone else's system, not a Buzz data model. +`VISION.md:9` argues against stitching outside services into the workspace. Keeping the calendar a +view onto someone else's system rather than a Buzz data model is what keeps that argument intact. + +Upstream RFC #3227 is narrower than this memo and only decision 9 follows it: it asks for an +extension point so an installed app can supply a managed agent carrying its own scoped credential +that never enters the agent sandbox. It says nothing about a human authorizing a third-party API +from the desktop, which is what decisions 1–8 are; those stand on their own arguments below. ## Why the two obvious alternatives are out @@ -49,12 +51,28 @@ Never requested: `https://www.googleapis.com/auth/calendar` (full calendar manag response and persisted; a user who unchecks a box gets the read-only surface, not a broken write path. +**The OAuth client itself.** The client lives in a Google Cloud project owned by the same Workspace +that owns the business calendar (decision 3), and is of type **Desktop app** — the type the +loopback-plus-PKCE flow above requires. Its consent screen is published **Internal**, which limits +it to that Workspace's own accounts and needs no Google verification review. Publishing status is +**In production**, never **Testing**: a client left in Testing expires every refresh token after +seven days, which would turn decision 8's terminal `invalid_grant` from an exception into a weekly +reconnect prompt for every user, and would make decision 7's propagation bound read as working +when the grant is simply dead. Shipping this beyond one Workspace means an **External** client and +Google verification for the `calendar.events` scope — a separate ticket, not a config toggle. The +installed-app client secret ships inside the binary and is **not confidential**; it is an +identifier, not a credential. + **Reason.** `calendarlist.readonly` is the only way to resolve the business calendar's id and the caller's `accessRole` without asking for calendar management. Splitting read from write means a member who only ever looks at the calendar never holds a token that can change it. Reading the granted scopes back rather than assuming them is what makes the read-only fallback real: Google's consent screen lets the user drop a scope, and an app that assumes it got what it asked for fails -at write time with a raw 403. +at write time with a raw 403. Naming the publishing status is not paperwork: it is the single +setting that decides whether decision 8's terminal branch is rare or weekly, so it belongs in the +contract rather than in whoever's memory set the project up. PKCE, not the client secret, is what +binds the authorization code to this app, so nothing in this memo rests on that secret staying +hidden — decision 2's keychain argument is about tokens, which are the real credential. ### 2. Which Google account binds to which Buzz identity, and how the binding is stored @@ -63,7 +81,8 @@ is the active identity's pubkey hex (`get_identity`, `desktop/src-tauri/src/comm the Google side is the OIDC `sub`, not the email. The record — refresh token, access token, expiry, granted scopes, `sub`, email for display — is stored in the OS keychain through `SecretStore` under a key namespaced by pubkey hex. `SecretStore` keeps all secrets as one JSON -blob (service `buzz-desktop`, username `secrets`, `desktop/src-tauri/src/secret_store.rs:1-21`), +blob (username `secrets`, `desktop/src-tauri/src/secret_store.rs:1-21`; the service-name constant +`buzz-desktop` is at `:50`), so this costs no extra keychain prompt. Token exchange, refresh and every Google API call happen in Rust. The webview receives a redacted status struct only: connected, email, granted scopes, expiry, state. Connecting a different Google account requires an explicit confirm and revokes the @@ -99,19 +118,26 @@ take the calendar with them. ### 4. Which channels show the calendar, and who chooses -**Decision.** The calendar surface is opt-in per channel. A channel admin (kind:39001, with -membership at kind:39002 — `crates/buzz-core/src/kind.rs:424-426`) chooses which calendar id a -channel maps to. For v1 that mapping is a local per-identity desktop setting in the app-data dir -next to the archive DB; a relay-synced mapping needs a new event kind and is deferred to an -upstream-first ticket (see "Why the two obvious alternatives are out"). The mapping is a -*display* choice and carries no authority: a channel member whose Google account is not on the -calendar's ACL sees an empty surface with "not shared with your account", never someone else's -events. +**Decision.** The calendar surface is opt-in per channel, and **in v1 each user sets the mapping +locally**: it is a per-identity desktop setting in the app-data dir next to the archive DB, and +nothing carries one person's choice to anyone else's installation. The channel admin (kind:39001, +with membership at kind:39002 — `crates/buzz-core/src/kind.rs:424-426`) decides which calendar id +the channel *should* use and conveys that choice out of band — a pinned message, the channel topic, +onboarding — so in v1 it is a convention the app does not enforce. Admin-owned mapping becomes +enforceable only with the relay-synced kind, which needs a new allow-listed event kind and is +deferred to an upstream-first ticket (see "Why the two obvious alternatives are out"). What T12 can +bind is therefore the local half: the mapping is per-identity and local, and no mapping grants +access. The mapping is a *display* choice and carries no authority — a channel member whose Google +account is not on the calendar's ACL sees an empty surface with "not shared with your account", +never someone else's events. **Reason.** Making the mapping powerless is what lets it be stored loosely. If a wrong or stale mapping could expose event data, it would need the same durability and audit as the ACL itself; because every read is made with the viewer's own token, the worst outcome of a bad mapping is an -empty panel in the wrong channel. That trade buys v1 out of a relay change we cannot make. +empty panel in the wrong channel. That trade buys v1 out of a relay change we cannot make, and it +is also what makes the v1 authority gap tolerable: an admin whose choice nobody's client enforces +cannot leak anything by being ignored, because the setting they would be enforcing has no power in +the first place. ### 5. Disconnect behavior @@ -130,11 +156,23 @@ caught error. Full sign-out already covers both halves: the boot reset renames t and calls `delete_all_with_legacy()` then `verify_fully_wiped()` on the keychain (`desktop/src-tauri/src/reset.rs:273,315`). +**This is not a per-device action.** Google's revocation endpoint revokes the grant for that OAuth +client and that Google account, not one machine's copy of it — and a Buzz identity is a pubkey that +can be live on more than one installation (decision 2 binds one Google account per identity *per +installation*). Disconnecting on the laptop therefore ends the desktop's grant too, and the second +installation finds out only as an `invalid_grant` on its next refresh (decision 8). We accept that +rather than engineer around it, but we do not let it arrive unexplained: the disconnect confirm +says so in words — "this disconnects Google Calendar on all your Buzz installations" — and the +resulting `needs_reconnect` elsewhere names the cause instead of showing a bare auth error. + **Reason.** `AGENTS.md` Review-Proven Rule 1 — a caught failure leaves a durable retry record or propagates; deleting the journal before the retry succeeds is exactly the PR #6269 defect. Rule 5 — one user action is one atomic persist, ordered so every prefix is consistent: a crash after step 3 leaves a revoked-or-pending grant and no local token, which is safe. Rule 2 — the generation -fence stops a completing refresh from resurrecting a deleted binding. +fence stops a completing refresh from resurrecting a deleted binding. Per-device revocation would +need a distinct OAuth client or a distinct Google account per machine; both are worse than the +cross-device effect, and a "disconnect" that quietly leaves a live grant on a machine the user no +longer has is the worst option of the three. ### 6. Cached event data on disconnect and on membership loss @@ -202,7 +240,9 @@ different `sub` is an account change and takes the explicit confirm from decisio the reconnect entry lives in two places, one of which does not depend on the broken surface rendering. Rule 4 — a terminal auth error must stop the loop, not retry forever against a grant that will never come back. Splitting transient from terminal is what stops a flaky network from -nagging the user to re-consent. +nagging the user to re-consent. That split only holds because the client is published In +production (decision 1): in Testing status every refresh token dies after seven days, and the +terminal branch stops being an exception and becomes the normal weekly experience. ### 9. What an agent may read or write @@ -232,14 +272,15 @@ scope it, and remove it in the same place they manage everyone else. semantics — T12a `docs/calendar-view-design`. - Implementation of this contract, the mock Google server and the live two-account checklist — T12 `feat/google-calendar`. -- Relay-synced channel → calendar mapping (needs a new allow-listed kind) — upstream-first, - not scheduled. +- Relay-synced channel → calendar mapping, and with it admin-owned rather than per-user mapping + (needs a new allow-listed kind) — upstream-first, not scheduled. - Agent calendar access through a separate Google principal — blocked on RFC #3227 landing upstream. ## Relates to -- Upstream RFC #3227 — app-integration agents with scoped credentials (the shape this follows). +- Upstream RFC #3227 — app-integration agents with scoped credentials (the shape decision 9 + follows; it does not cover decisions 1–8). - Upstream PR #1382 — the closed Google Calendar work T12 revives for the OAuth and storage half. - `2026-09-04-zs-feature-audit.md` §4 — the audit that ruled out a native kind, Cal.com and iframes. From 89a7e8f4bcab787d2839a454b6bce03c5e838669 Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:21:29 -0700 Subject: [PATCH 03/11] docs: decide connect-time failure UX and the Workspace-account assumption The memo enumerated four post-connect states but left connect-time failure undecided, while decision 1's Internal OAuth client makes one of its causes routine: Google's calendar ACL accepts addresses outside the Workspace, so a member can hold real read access in Google and still be unable to consent. - State the assumption: every member who should see the calendar has an account in the Workspace that owns the calendar and the client. Name what it costs and what the person outside it gets (Google's own web UI), and defer the External client that would change that. - Decide the connect-time UX. Two observables: an `error` redirect (Cancel) goes back to plain disconnected; no callback inside the listener's deadline is one state naming its three causes (window closed, account outside the Workspace, blocked loopback) because the app cannot tell them apart. Bind the loopback port before opening the browser so a bind failure is reported immediately. - Separate that vocabulary from "not shared with your account", which means a connected account the ACL does not list, and nothing else. - Add the driving scenario the decisions are scored against. - Correct decision 8: the seven-day refresh-token expiry binds External plus Testing, which decision 1's Internal choice already excludes. - Correct decision 2's citation: `BLOB_KEY` at secret_store.rs:42-44, and the service name from `keyring_service()` at app_state_keyring.rs:9-23, which is build-dependent rather than one constant. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 84 +++++++++++++++---- 1 file changed, 69 insertions(+), 15 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index eeeb18bcf87..3d97fb89985 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -14,6 +14,16 @@ OAuth flow; the desktop holds that grant in the OS keychain and renders exactly account's own API calls return. Buzz membership decides where the surface appears; Google decides what it contains. Agents get no calendar credential in v1. +**Driving scenario.** Broken English (the client in the plan's R1) keeps its teaching schedule in a +Google Workspace calendar. A teacher opens the school's Buzz channel and expects this week's +classes, and expects to move one when a class is rescheduled. Someone in the same channel who is +not on that calendar's ACL must see nothing. When the school offboards a teacher in Google +Workspace on a Friday, that teacher's view has to go away without anyone touching Buzz. The +decisions below are scored against that scenario. + +The contract assumes every member who should see the calendar has an account in the Workspace that +owns it. Decision 1 states what that assumption costs and what happens when it is false. + `VISION.md:9` argues against stitching outside services into the workspace. Keeping the calendar a view onto someone else's system rather than a Buzz data model is what keeps that argument intact. @@ -63,6 +73,35 @@ Google verification for the `calendar.events` scope — a separate ticket, not a installed-app client secret ships inside the binary and is **not confidential**; it is an identifier, not a credential. +**Who can connect.** Publishing the client Internal is also an eligibility rule: only accounts in +that Workspace can consent to it. This contract therefore assumes every Buzz member who should see +the business calendar has a Workspace account. The assumption is not free — Google's calendar ACL +accepts an address outside the Workspace (decision 3), so a member using a personal Google account +can hold real read access in Google and still be unable to consent here at all. In v1 that person +reads the calendar in Google's own web UI and gets no Buzz surface; giving them one means the +External client and the verification ticket named above. Onboarding someone for the calendar is +therefore "give them a Workspace account", not "add their address to the ACL". + +**When connect fails.** Connect-time failure is its own axis, not one of decision 6's post-connect +states, and it produces exactly two observables: + +- **Google redirects back with an `error` parameter** — the user pressed Cancel (`access_denied`). + The surface returns to plain disconnected: "Google Calendar is not connected", Connect still + offered, no dialog and no retry loop. The parameter is logged, never shown (decision 8). +- **No callback arrives** before the loopback listener's five-minute deadline, after which it + closes. This one observable covers three causes — the browser window was closed, the account is + outside the Workspace (an Internal client answers that in the browser with its own error page and + never redirects), or something local blocked the loopback (a firewall, a proxy, a browser that + refuses `http://127.0.0.1`). The app cannot tell them apart, so it does not guess: one "Couldn't + finish connecting" state that lists those three causes in that order, names the Workspace domain + this client accepts, and offers Try again. + +The listener binds its port *before* the system browser opens, so a bind failure is reported in the +app while the user is still looking at it instead of as silence five minutes later. None of these +states is "not shared with your account" (decisions 4 and 6): that message means a connected +account the calendar's ACL does not list, and showing it to someone who never reached the consent +screen sends them to an admin to fix an ACL that is not the problem. + **Reason.** `calendarlist.readonly` is the only way to resolve the business calendar's id and the caller's `accessRole` without asking for calendar management. Splitting read from write means a member who only ever looks at the calendar never holds a token that can change it. Reading the @@ -72,7 +111,12 @@ at write time with a raw 403. Naming the publishing status is not paperwork: it setting that decides whether decision 8's terminal branch is rare or weekly, so it belongs in the contract rather than in whoever's memory set the project up. PKCE, not the client secret, is what binds the authorization code to this app, so nothing in this memo rests on that secret staying -hidden — decision 2's keychain argument is about tokens, which are the real credential. +hidden — decision 2's keychain argument is about tokens, which are the real credential. Naming the +connect-time states is the same kind of correction: the rest of this memo speaks in ACL terms, and +an account that cannot use this client at all has no ACL problem to fix, so it must not be told it +has one. Refusing to guess which of the three silent causes occurred is deliberate — a confidently +wrong message ("your account is not in the Workspace") sends a user who merely closed the browser +window to a Workspace admin. ### 2. Which Google account binds to which Buzz identity, and how the binding is stored @@ -81,13 +125,14 @@ is the active identity's pubkey hex (`get_identity`, `desktop/src-tauri/src/comm the Google side is the OIDC `sub`, not the email. The record — refresh token, access token, expiry, granted scopes, `sub`, email for display — is stored in the OS keychain through `SecretStore` under a key namespaced by pubkey hex. `SecretStore` keeps all secrets as one JSON -blob (username `secrets`, `desktop/src-tauri/src/secret_store.rs:1-21`; the service-name constant -`buzz-desktop` is at `:50`), -so this costs no extra keychain prompt. Token exchange, refresh and every Google API call happen -in Rust. The webview receives a redacted status struct only: connected, email, granted scopes, -expiry, state. Connecting a different Google account requires an explicit confirm and revokes the -previous grant first (decision 5). The pubkey → Google-account mapping is never published to the -relay. +blob (the `BLOB_KEY` username `secrets`, `desktop/src-tauri/src/secret_store.rs:42-44`; the +service name is not a constant in that file but comes from `keyring_service()`, +`desktop/src-tauri/src/app_state_keyring.rs:9-23`, which returns `buzz-desktop` for release builds +and a `buzz-desktop-dev*` service otherwise), so this costs no extra keychain prompt. Token +exchange, refresh and every Google API call happen in Rust. The webview receives a redacted status +struct only: connected, email, granted scopes, expiry, state. Connecting a different Google +account requires an explicit confirm and revokes the previous grant first (decision 5). The +pubkey → Google-account mapping is never published to the relay. **Reason.** `sub` is stable; a Workspace email can be renamed or reassigned to a different human, and a binding keyed on email would silently follow the address to the new person. Keeping tokens @@ -105,7 +150,9 @@ schedule, "See all event details" for everyone else. Buzz never creates, grants, revokes a calendar ACL, and holds no scope that would let it. Edit affordances in the UI derive from the `accessRole` that `calendarList` returns for that user (`owner`/`writer` enable edit; `reader`/`freeBusyReader` disable it), and a write Google rejects surfaces as a failure — never as -a local "saved" state. +a local "saved" state. Google's ACL also accepts addresses outside the Workspace, and we do not +restrict that: such a grant is real read access in Google, it simply produces no Buzz surface, +because the OAuth client is Internal (decision 1). **Reason.** Two access-control systems that can disagree is the failure mode the feature audit named (`2026-09-04-zs-feature-audit.md:57`). Buzz's only gate is channel membership @@ -127,9 +174,10 @@ onboarding — so in v1 it is a convention the app does not enforce. Admin-owned enforceable only with the relay-synced kind, which needs a new allow-listed event kind and is deferred to an upstream-first ticket (see "Why the two obvious alternatives are out"). What T12 can bind is therefore the local half: the mapping is per-identity and local, and no mapping grants -access. The mapping is a *display* choice and carries no authority — a channel member whose Google -account is not on the calendar's ACL sees an empty surface with "not shared with your account", -never someone else's events. +access. The mapping is a *display* choice and carries no authority — a *connected* channel member +whose Google account is not on the calendar's ACL sees an empty surface with "not shared with your +account", never someone else's events. That message is about the ACL and nothing else; a member who +could not connect in the first place gets decision 1's connect-time state instead. **Reason.** Making the mapping powerless is what lets it be stored loosely. If a wrong or stale mapping could expose event data, it would need the same durability and audit as the ACL itself; @@ -240,9 +288,12 @@ different `sub` is an account change and takes the explicit confirm from decisio the reconnect entry lives in two places, one of which does not depend on the broken surface rendering. Rule 4 — a terminal auth error must stop the loop, not retry forever against a grant that will never come back. Splitting transient from terminal is what stops a flaky network from -nagging the user to re-consent. That split only holds because the client is published In -production (decision 1): in Testing status every refresh token dies after seven days, and the -terminal branch stops being an exception and becomes the normal weekly experience. +nagging the user to re-consent. The split also depends on the client's publishing status +(decision 1). Google's seven-day refresh-token expiry binds a client whose user type is External +and whose publishing status is Testing; decision 1's Internal choice already excludes it, so the +"In production, never Testing" rule is belt-and-braces here and load-bearing for the External +client the shipping-beyond-one-Workspace ticket would need. Left in that state, the terminal branch +would stop being an exception and become the normal weekly experience. ### 9. What an agent may read or write @@ -274,6 +325,9 @@ scope it, and remove it in the same place they manage everyone else. T12 `feat/google-calendar`. - Relay-synced channel → calendar mapping, and with it admin-owned rather than per-user mapping (needs a new allow-listed kind) — upstream-first, not scheduled. +- A Buzz calendar surface for members outside the Workspace that owns the client (an External + OAuth client plus Google verification for `calendar.events`, decision 1) — a separate ticket, + not scheduled. - Agent calendar access through a separate Google principal — blocked on RFC #3227 landing upstream. From c856724a60bf7f25ba1885cb53f1cd5f6cdea9d2 Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:03:41 -0700 Subject: [PATCH 04/11] docs: fix nine authorization defects found in the Sol audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 1: drop incremental authorization (Google does not support it for installed apps) — one connect request for the full read+write union, surface derived from the token response scope readback, and a complete re-authorization that replaces the credential only after the returned scope string is verified. Require a Buzz-only Cloud project, because Google revokes at project granularity. Add the authorization transaction (unpredictable state, PKCE verifier, initiating pubkey, redirect URI, deadline) validated in full at the callback, so an identity switch mid-flow cannot bind A's Google account under B's pubkey. Replace "exactly two" connect outcomes with the exhaustive pipeline, including keychain preflight and the persist-failure-after-grant path. Decision 3: complete the accessRole table with writerWithoutPrivateAccess and close it — any unnamed role is read-only. Decision 5: split the revocation tombstone out of the binding record so disconnect no longer deletes the journal its own retry depends on (PR #6269), and give sign-out a stated best-effort-revoke-then-disclose policy. Restate the blast radius in project terms so the confirm text matches the real effect. Decision 6: classify failures on status plus structured reason plus operation — rate and quota 403s are transient, write-authorization reasons fail only that write, and only a verified read failure purges. Absence from calendarList is no longer evidence: page to exhaustion with showHidden=true. Decisions 6 and 8: split the 24-hour ceiling into an `unreachable` state whose action is Retry, reserving needs_reconnect for the terminal auth class. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 400 +++++++++++++----- 1 file changed, 288 insertions(+), 112 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index 3d97fb89985..6d960c80230 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -48,25 +48,53 @@ from the desktop, which is what decisions 1–8 are; those stand on their own ar ### 1. OAuth scopes requested **Decision.** Installed-app authorization-code flow with PKCE (S256) and a loopback redirect on -`http://127.0.0.1:/oauth/callback`, opened in the system browser. Two stages: +`http://127.0.0.1:/oauth/callback`, opened in the system browser. **One authorization +request, not two.** Connect asks for the complete set the surface can ever need: -- At connect: `openid`, `https://www.googleapis.com/auth/userinfo.email`, - `https://www.googleapis.com/auth/calendar.calendarlist.readonly`, - `https://www.googleapis.com/auth/calendar.events.readonly`. -- At the first edit attempt, by incremental authorization (`include_granted_scopes=true`): - `https://www.googleapis.com/auth/calendar.events`. +- `openid` +- `https://www.googleapis.com/auth/userinfo.email` +- `https://www.googleapis.com/auth/calendar.calendarlist.readonly` +- `https://www.googleapis.com/auth/calendar.events.readonly` +- `https://www.googleapis.com/auth/calendar.events` Never requested: `https://www.googleapis.com/auth/calendar` (full calendar management) and -`https://www.googleapis.com/auth/calendar.acl`. The granted scopes are read back from the token -response and persisted; a user who unchecks a box gets the read-only surface, not a broken write -path. - -**The OAuth client itself.** The client lives in a Google Cloud project owned by the same Workspace -that owns the business calendar (decision 3), and is of type **Desktop app** — the type the -loopback-plus-PKCE flow above requires. Its consent screen is published **Internal**, which limits -it to that Workspace's own accounts and needs no Google verification review. Publishing status is -**In production**, never **Testing**: a client left in Testing expires every refresh token after -seven days, which would turn decision 8's terminal `invalid_grant` from an exception into a weekly +`https://www.googleapis.com/auth/calendar.acl`. + +**The granted set is read back from the token response's `scope` string, and it is the only thing +the surface trusts.** Google's granular consent lets a user grant part of what was asked, so the +read-only surface is produced by the readback, not by a smaller request: + +- the read surface needs `calendar.calendarlist.readonly` **and** at least one of + `calendar.events.readonly` / `calendar.events`; +- edit affordances need `calendar.events` **and** decision 3's `accessRole` test; +- anything short of the read set is a *connect failure*, not a degraded surface: the credential is + not persisted, the grant just issued is revoked (see "When connect fails"), and the user is told + which permission the calendar view requires. + +**No incremental authorization.** Google's OAuth 2.0 for Mobile & Desktop Apps guide states that +incremental authorization is not supported for installed apps or devices, and this decision pins +the client to type Desktop app, so `include_granted_scopes=true` is never sent. When the granted +set has to change later — the user unchecked write at consent and now wants to schedule — Buzz +runs a **complete new authorization for the whole union above**, and replaces the stored credential +only after the new token response's `scope` string is verified to contain every scope the current +surface depends on. If it does not, the exchange is abandoned, the newly issued token is revoked, +the existing credential is left untouched, and the surface reports that the extra permission was +not granted. A re-authorization can never leave an account holding less than it held before. + +**A dedicated Google Cloud project.** The OAuth client lives in a Google Cloud project that +contains nothing but this client, owned by the same Workspace that owns the business calendar +(decision 3). This is a requirement, not a preference: Google's revocation semantics are +per *project*, not per client — revoking removes every scope the user previously granted to the +project and invalidates the tokens of every client registered under it. A shared project holding a +dev client, a staging client or an internal script would make decision 5's Disconnect silently +invalidate that user's tokens for all of them. A Buzz-only project is what makes the blast radius +of Disconnect exactly "Buzz", which is what decision 5's confirm text claims it is. + +**The OAuth client itself.** The client is of type **Desktop app** — the type the loopback-plus-PKCE +flow above requires. Its consent screen is published **Internal**, which limits it to that +Workspace's own accounts and needs no Google verification review. Publishing status is **In +production**, never **Testing**: a client left in Testing expires every refresh token after seven +days, which would turn decision 8's terminal `invalid_grant` from an exception into a weekly reconnect prompt for every user, and would make decision 7's propagation bound read as working when the grant is simply dead. Shipping this beyond one Workspace means an **External** client and Google verification for the `calendar.events` scope — a separate ticket, not a config toggle. The @@ -82,41 +110,101 @@ reads the calendar in Google's own web UI and gets no Buzz surface; giving them External client and the verification ticket named above. Onboarding someone for the calendar is therefore "give them a Workspace account", not "add their address to the ACL". +**The authorization transaction.** Nothing about a callback is trusted on its own. Before the +system browser opens, the app creates one transaction record, held in memory only, containing: an +unpredictable `state` (128 bits from the OS CSPRNG), the PKCE verifier, the initiating identity's +pubkey hex, the redirect URI including the bound port, and a deadline five minutes out. At the +callback, all of it is validated in this order before any token exchange: + +1. **`state` matches.** A missing, wrong or already-spent `state` is dropped: no exchange, no + user-visible change, and the listener keeps waiting until its deadline. This is what stops + another local process from driving an uncorrelated callback into the loopback port. +2. **The transaction is unspent and inside its deadline.** It is marked spent before the exchange, + so a second callback replaying a valid `state` is a replay and is dropped. +3. **The active identity still matches the initiating pubkey** — checked before the exchange and + again at persist time while holding the identity mutation lock. Identity can change under a + live flow: `import_identity` is a live command + (`desktop/src-tauri/src/commands/identity.rs:337`) and the codebase already serializes identity + mutation against itself (`identity.rs:229,355,477`). A mismatch at either point aborts the + flow: nothing is written under the new identity, and a token already issued by the exchange is + revoked before the failure is reported. + +Without step 3 the credential for pubkey A's Google account would be written under pubkey B, B's +surface would render A's calendar, and decision 2's "connecting a different Google account requires +an explicit confirm" would never fire, because from B's side that write is a first connect. + +T12 names a test for each: a wrong `state`, a missing `state`, a second concurrent callback +replaying a valid `state`, a callback after the deadline, and an identity switch between the +browser opening and the callback. + **When connect fails.** Connect-time failure is its own axis, not one of decision 6's post-connect -states, and it produces exactly two observables: - -- **Google redirects back with an `error` parameter** — the user pressed Cancel (`access_denied`). - The surface returns to plain disconnected: "Google Calendar is not connected", Connect still - offered, no dialog and no retry loop. The parameter is logged, never shown (decision 8). -- **No callback arrives** before the loopback listener's five-minute deadline, after which it - closes. This one observable covers three causes — the browser window was closed, the account is - outside the Workspace (an Internal client answers that in the browser with its own error page and - never redirects), or something local blocked the loopback (a firewall, a proxy, a browser that - refuses `http://127.0.0.1`). The app cannot tell them apart, so it does not guess: one "Couldn't - finish connecting" state that lists those three causes in that order, names the Workspace domain - this client accepts, and offers Try again. - -The listener binds its port *before* the system browser opens, so a bind failure is reported in the -app while the user is still looking at it instead of as silence five minutes later. None of these +states. Connect is a pipeline, and every stage has a failure; the list is exhaustive on purpose, +because the dangerous states are the late ones where a Google grant exists and we hold nothing: + +0. **Preflight — before the browser opens.** The loopback listener binds its port, and + `SecretStore` is probed with a write-and-delete of a scratch key. A bind failure or a locked or + unavailable keychain is reported in the app while the user is still looking at it, and no + browser opens. There is no plaintext fallback for a calendar credential, ever (decision 2). +1. **Authorized / not authorized.** Two observables here: + - **Google redirects back with an `error` parameter** — the user pressed Cancel + (`access_denied`). The surface returns to plain disconnected: "Google Calendar is not + connected", Connect still offered, no dialog and no retry loop. The parameter is logged, + never shown (decision 8). + - **No callback arrives** before the five-minute deadline, after which the listener closes. + This covers three causes — the browser window was closed, the account is outside the + Workspace (an Internal client answers that in the browser with its own error page and never + redirects), or something local blocked the loopback (a firewall, a proxy, a browser that + refuses `http://127.0.0.1`). The app cannot tell them apart, so it does not guess: one + "Couldn't finish connecting" state that lists those three causes in that order, names the + Workspace domain this client accepts, and offers Try again. +2. **Exchanged / exchange failed.** A `state`-matched callback whose code exchange fails (network, + 5xx, an expired or replayed code). No token was issued, so there is nothing to revoke, but the + user's consent already created a grant record on their account. The state is "Couldn't finish + connecting", with Try again, and one sentence saying Buzz may appear in their Google account's + third-party access list until they retry or remove it there. Retry is a fresh transaction, never + a reuse of the spent one. +3. **Scope-verified / insufficient.** The readback rule above. An insufficient grant revokes the + token just issued and persists nothing. +4. **Persisted / persist failed.** The exchange succeeded, so a live grant exists on the user's + account and we hold its refresh token. `SecretStore::store` returns `Result<(), String>` + (`desktop/src-tauri/src/secret_store.rs:729`), and a locked or unavailable keychain is a real + return value even after the preflight passed. On `Err` the app does not fall back to plaintext + and does not report a bare failure: it revokes the refresh token at Google, and reports + "Couldn't finish connecting" once the revocation returns 200. If the revocation also fails, it + writes decision 5's revocation tombstone — a separate, much smaller record, attempted even + though the credential write failed — and the tombstone's retry schedule finishes the job. If the + tombstone cannot be written either, the failure message says in words that a Google grant for + Buzz may still exist, and links to the user's Google account permissions page. A grant with no + local record and no durable instruction to remove it is exactly Review-Proven Rule 1's + catch-with-no-durable-record, so it is the one outcome this list refuses to leave silent. + +T12 names a test for an exchange failure (no credential, no partial record, no tombstone), for a +persist failure after a successful exchange (the issued token is revoked and the failure is +reported), and for a persist failure whose revocation also fails (a tombstone survives and the +next launch retries it). + +None of these states is "not shared with your account" (decisions 4 and 6): that message means a connected account the calendar's ACL does not list, and showing it to someone who never reached the consent screen sends them to an admin to fix an ACL that is not the problem. **Reason.** `calendarlist.readonly` is the only way to resolve the business calendar's id and the -caller's `accessRole` without asking for calendar management. Splitting read from write means a -member who only ever looks at the calendar never holds a token that can change it. Reading the -granted scopes back rather than assuming them is what makes the read-only fallback real: Google's -consent screen lets the user drop a scope, and an app that assumes it got what it asked for fails -at write time with a raw 403. Naming the publishing status is not paperwork: it is the single -setting that decides whether decision 8's terminal branch is rare or weekly, so it belongs in the -contract rather than in whoever's memory set the project up. PKCE, not the client secret, is what -binds the authorization code to this app, so nothing in this memo rests on that secret staying -hidden — decision 2's keychain argument is about tokens, which are the real credential. Naming the -connect-time states is the same kind of correction: the rest of this memo speaks in ACL terms, and -an account that cannot use this client at all has no ACL problem to fix, so it must not be told it -has one. Refusing to guess which of the three silent causes occurred is deliberate — a confidently -wrong message ("your account is not in the Workspace") sends a user who merely closed the browser -window to a Workspace admin. +caller's `accessRole` without asking for calendar management. Asking for the whole union at connect +and deriving the surface from the readback is what keeps "read-only member" a real state without a +second authorization: Google will not give an installed app an incremental upgrade, so a design +that depends on one would hand a teacher who drags a class a token carrying only `calendar.events`, +and the same readback rule that protects the read-only surface would then correctly record that +`calendarlist.readonly` and `events.readonly` are gone — trying to edit would break reading. +Verifying the full union before replacing a credential is the same rule applied to the +re-authorization path. Naming the publishing status is not paperwork: it is the single setting that +decides whether decision 8's terminal branch is rare or weekly. Requiring a Buzz-only Cloud project +is not paperwork either: it is the only thing that makes Disconnect's user-facing promise true, +because Google revokes at project granularity. PKCE, not the client secret, is what binds the +authorization code to this app, so nothing here rests on that secret staying hidden — but PKCE +binds the code to the *app*, not to the *identity that asked*, which is what the transaction's +`state` and pubkey check add. Refusing to guess which of the three silent causes occurred is +deliberate — a confidently wrong message ("your account is not in the Workspace") sends a user who +merely closed the browser window to a Workspace admin. ### 2. Which Google account binds to which Buzz identity, and how the binding is stored @@ -128,11 +216,13 @@ expiry, granted scopes, `sub`, email for display — is stored in the OS keychai blob (the `BLOB_KEY` username `secrets`, `desktop/src-tauri/src/secret_store.rs:42-44`; the service name is not a constant in that file but comes from `keyring_service()`, `desktop/src-tauri/src/app_state_keyring.rs:9-23`, which returns `buzz-desktop` for release builds -and a `buzz-desktop-dev*` service otherwise), so this costs no extra keychain prompt. Token -exchange, refresh and every Google API call happen in Rust. The webview receives a redacted status -struct only: connected, email, granted scopes, expiry, state. Connecting a different Google -account requires an explicit confirm and revokes the previous grant first (decision 5). The -pubkey → Google-account mapping is never published to the relay. +and a `buzz-desktop-dev*` service otherwise), so this costs no extra keychain prompt. Decision 5's +revocation tombstone is a *second, separate key* in that same blob; it is deliberately not a field +of this record, because it has to outlive it. Token exchange, refresh and every Google API call +happen in Rust. The webview receives a redacted status struct only: connected, email, granted +scopes, expiry, state. Connecting a different Google account requires an explicit confirm and +revokes the previous grant first (decision 5). The pubkey → Google-account mapping is never +published to the relay. **Reason.** `sub` is stable; a Workspace email can be renamed or reassigned to a different human, and a binding keyed on email would silently follow the address to the new person. Keeping tokens @@ -147,12 +237,26 @@ holds which Google account. Workspace (an admin-held account or a resource account), not by any individual's primary calendar. All sharing is granted in Google Calendar's own ACL — "Make changes to events" for staff who schedule, "See all event details" for everyone else. Buzz never creates, grants, changes or -revokes a calendar ACL, and holds no scope that would let it. Edit affordances in the UI derive -from the `accessRole` that `calendarList` returns for that user (`owner`/`writer` enable edit; -`reader`/`freeBusyReader` disable it), and a write Google rejects surfaces as a failure — never as -a local "saved" state. Google's ACL also accepts addresses outside the Workspace, and we do not -restrict that: such a grant is real read access in Google, it simply produces no Buzz surface, -because the OAuth client is Internal (decision 1). +revokes a calendar ACL, and holds no scope that would let it. A write Google rejects surfaces as a +failure — never as a local "saved" state. Google's ACL also accepts addresses outside the +Workspace, and we do not restrict that: such a grant is real read access in Google, it simply +produces no Buzz surface, because the OAuth client is Internal (decision 1). + +Edit affordances derive from the `accessRole` that `calendarList` returns for that user, and the +table is closed: + +| `accessRole` | Edit affordances | +|---|---| +| `owner`, `writer` | enabled for every event on the calendar | +| `writerWithoutPrivateAccess` | enabled for the events the API returns in full; disabled for events returned as free/busy only, which render as busy blocks with no edit affordance | +| `reader`, `freeBusyReader` | disabled | +| **any other value, present or future** | **disabled — treated as read-only** | + +The last row is the rule, not a placeholder. Google adds roles; an unrecognized role must never +default to allow, because the resulting edit fails at Google with a raw error, which this decision +forbids, and it must not be an implementation choice, because "hide edit" and "default allow" are +both defensible in isolation and only one of them is safe. T12 tests one case per row, including +an invented unknown role. **Reason.** Two access-control systems that can disagree is the failure mode the feature audit named (`2026-09-04-zs-feature-audit.md:57`). Buzz's only gate is channel membership @@ -161,7 +265,8 @@ of the other, and the copy will be wrong the day someone is offboarded. Instead access decision at all: it renders each account's own answer. Removing someone from the business means removing them in Google Workspace, which is the same sentence the audit already wrote. Owning the calendar with the Workspace rather than a person means an offboarded owner does not -take the calendar with them. +take the calendar with them. Closing the role table is the same discipline one level down: the +surface derives from Google's answer, including the answers Google has not invented yet. ### 4. Which channels show the calendar, and who chooses @@ -177,7 +282,8 @@ bind is therefore the local half: the mapping is per-identity and local, and no access. The mapping is a *display* choice and carries no authority — a *connected* channel member whose Google account is not on the calendar's ACL sees an empty surface with "not shared with your account", never someone else's events. That message is about the ACL and nothing else; a member who -could not connect in the first place gets decision 1's connect-time state instead. +could not connect in the first place gets decision 1's connect-time state instead, and a member +whose calendar simply could not be reached gets decision 6's unreachable state. **Reason.** Making the mapping powerless is what lets it be stored loosely. If a wrong or stale mapping could expose event data, it would need the same durability and audit as the ACL itself; @@ -189,38 +295,68 @@ the first place. ### 5. Disconnect behavior -**Decision.** "Disconnect" in Buzz is one user action with an ordered, resumable effect: - -1. Write the binding record to `revoke_pending` (one persist, not three). -2. Call Google's revocation endpoint with the refresh token. -3. Delete the keychain record (`SecretStore::delete`), drop the in-memory token cache, and fence - any in-flight refresh by generation so a late response cannot rewrite a deleted record. -4. Purge the cached events for that binding (decision 6). -5. Leave the channel mapping in place, so reconnecting returns to the same view. - -If step 2 fails (offline, 5xx), steps 3–5 still run and the `revoke_pending` record survives as a -durable retry journal, retried on next launch until Google confirms; it is never dropped on a -caught error. Full sign-out already covers both halves: the boot reset renames the app-data dir -and calls `delete_all_with_legacy()` then `verify_fully_wiped()` on the keychain -(`desktop/src-tauri/src/reset.rs:273,315`). - -**This is not a per-device action.** Google's revocation endpoint revokes the grant for that OAuth -client and that Google account, not one machine's copy of it — and a Buzz identity is a pubkey that -can be live on more than one installation (decision 2 binds one Google account per identity *per -installation*). Disconnecting on the laptop therefore ends the desktop's grant too, and the second -installation finds out only as an `invalid_grant` on its next refresh (decision 8). We accept that -rather than engineer around it, but we do not let it arrive unexplained: the disconnect confirm -says so in words — "this disconnects Google Calendar on all your Buzz installations" — and the -resulting `needs_reconnect` elsewhere names the cause instead of showing a bare auth error. +**Decision.** "Disconnect" is one user action with an ordered, resumable effect. It works on two +*separate* keychain records, and the separation is the whole point: + +- the **binding record** of decision 2 — refresh token, access token, expiry, granted scopes, + `sub`, email — under the pubkey-namespaced key; +- the **revocation tombstone** — refresh token, `sub`, attempt count, first-attempt time — under + its own key in the same `SecretStore` blob, and never touched by the code paths that delete a + binding. + +The ordered effect: + +1. Write the revocation tombstone (one persist, not three). +2. Delete the binding record (`SecretStore::delete`, + `desktop/src-tauri/src/secret_store.rs:901`), drop the in-memory token cache, and fence any + in-flight refresh by generation so a late response cannot rewrite a deleted record. +3. Purge the cached events for that binding (decision 6). +4. Call Google's revocation endpoint with the refresh token read from the tombstone. +5. Delete the tombstone **only** on HTTP 200, or on a terminal `invalid_token` response, which + means the grant is already gone. +6. Leave the channel mapping in place, so reconnecting returns to the same view. + +Because the tombstone is written first and holds everything the retry needs, every prefix of that +sequence is consistent: a crash, an offline machine or a 5xx leaves no usable local credential and +a durable instruction to finish the revocation. Retries run at next launch and on network recovery +with backoff and a capped attempt count; at the cap the tombstone is **kept**, not dropped, and +settings shows one line — "Google Calendar revocation is still pending" — with Retry and a link to +the user's Google permissions page. Deleting the record the retry depends on is exactly the PR +#6269 defect. T12 names a test for a transient revocation failure that resumes across a restart. + +**Sign-out.** Full sign-out is a wipe, not a disconnect, and it needs a stated policy rather than +an accident: the boot reset renames the app-data dir and calls `delete_all_with_legacy()` then +`verify_fully_wiped()` on the keychain (`desktop/src-tauri/src/reset.rs:273,315`), which removes +the whole blob — the tombstone with it, and the token the revocation needs. So sign-out attempts +the revocation once before the wipe, bounded to a few seconds with no retry loop. If it succeeds, +the wipe proceeds normally. If it does not, the confirm says so in words — "Your Google Calendar +grant could not be revoked and stays active until you remove Buzz at your Google account page", +with the link — and the wipe proceeds anyway, because a sign-out that refuses to sign out is +worse than a disclosed live grant. T12 names a test for sign-out with a revocation pending. + +**This is not a per-device action, and its blast radius is the Cloud project.** Google's +revocation endpoint removes every scope the user previously granted to the OAuth client's *Cloud +project* and invalidates the tokens of every client registered under that project — not one +machine's copy, and not only the client that called it. Decision 1 requires a Buzz-only project +precisely so that "every client under the project" means Buzz and nothing else; without that +requirement, a teacher pressing Disconnect could invalidate their tokens for a sibling client the +confirm text never mentioned. Within Buzz, an identity's pubkey can be live on more than one +installation (decision 2 binds one Google account per identity *per installation*), so +disconnecting on the laptop ends the desktop's grant too, and the second installation finds out +only as an `invalid_grant` on its next refresh (decision 8). We accept that rather than engineer +around it, but we do not let it arrive unexplained: the confirm reads "this disconnects Google +Calendar for Buzz on all your devices", and the resulting `needs_reconnect` elsewhere names the +cause instead of showing a bare auth error. **Reason.** `AGENTS.md` Review-Proven Rule 1 — a caught failure leaves a durable retry record or -propagates; deleting the journal before the retry succeeds is exactly the PR #6269 defect. Rule 5 -— one user action is one atomic persist, ordered so every prefix is consistent: a crash after step -3 leaves a revoked-or-pending grant and no local token, which is safe. Rule 2 — the generation -fence stops a completing refresh from resurrecting a deleted binding. Per-device revocation would -need a distinct OAuth client or a distinct Google account per machine; both are worse than the -cross-device effect, and a "disconnect" that quietly leaves a live grant on a machine the user no -longer has is the worst option of the three. +propagates; a journal stored *inside* the record the operation deletes is not a journal, which is +why the tombstone is its own key with its own deletion rule. Rule 5 — one user action is one +atomic persist, ordered so every prefix is consistent: a crash after step 2 leaves a +revoked-or-pending grant, no local token and a live instruction to finish, which is safe. Rule 2 +— the generation fence stops a completing refresh from resurrecting a deleted binding. +Per-device revocation would need a distinct OAuth client or a distinct Google account per machine; +both are worse than the cross-device effect, and a "disconnect" that quietly leaves a live grant +on a machine the user no longer has is the worst option of the three. ### 6. Cached event data on disconnect and on membership loss @@ -235,19 +371,48 @@ carries the timestamp of the refresh that produced it. - **On loss of Buzz channel membership:** the channel's mapping row is dropped and the surface disappears from that channel; the event cache is *not* purged. The user's Google access did not change. -- **On loss of Google access** (403/404 on the calendar, or it stops appearing in - `calendarList`): the calendar's cached rows are purged on that response and the surface shows - "no longer shared with your account". +- **On loss of Google access:** only a *verified read failure on the calendar resource itself* + purges, classified by the table below. - **When we cannot tell** (network failure, refresh failing): the last-good view is shown marked stale with its refresh time, edits are disabled, and at a 24-hour staleness ceiling the events - are dropped and the surface asks for a reconnect. + are dropped and the binding enters the **`unreachable`** state — Retry plus a line about the + network, *not* decision 8's `needs_reconnect`. + +**Classifying a failure.** A 403 is never classified on its status alone. The decision is a +function of the HTTP status, the structured `reason` in the error body, and the operation that +produced it: + +| Response | Class | Effect | +|---|---|---| +| 403 `userRateLimitExceeded`, `rateLimitExceeded`, `quotaExceeded` | transient (decision 8) | keep the cache, back off; identical handling to 429 | +| 403 `forbiddenForNonOrganizer` | write-authorization | fails that write only; the read path and the cache are untouched | +| 403 `insufficientPermissions` on a write | write-authorization | edit affordances drop to read-only; if the scope readback shows `calendar.events` absent, decision 1's re-authorization is offered | +| 403 with an access reason, or 404, on a **read** of the calendar or its events, still failing after one backed-off retry | access loss | purge that calendar's cached rows; surface "no longer shared with your account" | +| 401, or refresh returning `invalid_grant` | terminal auth (decision 8) | `needs_reconnect` | +| network failure, 5xx, 429 | transient (decision 8) | keep the cache, back off | + +An unrecognized `reason` on a read is treated as transient and retried; only a second failure +after backoff can be access loss. Erring toward transient is deliberate and asymmetric: a wrong +transient call costs one stale poll interval, while a wrong access-loss call purges a cache and +sends a teacher to an administrator to hunt an ACL that was never wrong. T12 names a test per row. + +**Absence from `calendarList` is not evidence of anything.** The list call hides calendars for two +ordinary reasons: `showHidden` defaults to false, and `maxResults` defaults to 100 entries with +`nextPageToken` paging. T12's list call therefore sets `showHidden=true` and pages to exhaustion +before drawing any conclusion — and even then, absence only means "do not offer this calendar in +the picker". It never purges and never produces the "no longer shared" message; only a direct, +classified failure on the calendar resource does. T12 tests a calendar the user hid in Google's own +UI and a calendar sorted past entry 100: both resolve, and neither purges. **Reason.** Purging on Buzz membership loss would mean Buzz is enforcing Google's ACL, which decision 3 refuses; the user still has the calendar in Google, and their local copy is theirs. The staleness ceiling is the other half of that: an unbounded offline cache would keep showing a -calendar the user may have lost, with no bound on how long. Disabling edits from stale state stops -a write built on data we already know may be wrong. Keeping the cache out of the archive DB keeps -its access-proof invariant honest. +calendar the user may have lost, with no bound on how long. Dropping the events at the ceiling +while calling the state `unreachable` rather than `needs_reconnect` is the point of decision 8's +split — the grant is not known to be broken, and a teacher back from a weekend with no signal must +not be handed a Reconnect button that opens a browser which cannot reach Google either. Disabling +edits from stale state stops a write built on data we already know may be wrong. Keeping the cache +out of the archive DB keeps its access-proof invariant honest. ### 7. Revocation propagation timing @@ -265,35 +430,46 @@ refresh returns `invalid_grant`, which is decision 8. **Reason.** The honest statement of a poll-based system is its interval, not "immediately". Naming the worst case makes it reviewable; a webhook would shorten it but costs a public endpoint the fork does not want, so the trade is written down instead of hidden. The backoff and the terminal -state are Rule 4: a persistent failure must not amplify into an unbounded refresh loop. +state are Rule 4: a persistent failure must not amplify into an unbounded refresh loop. The poll +interval is also why decision 6 puts Google's rate and quota 403s in the transient class: a whole +staff room opening Buzz at five to nine is a load spike, not an ACL change. ### 8. Refresh-token failure UX -**Decision.** Two classes, handled differently. +**Decision.** Three states, handled differently. -- **Transient** (network, 5xx, 429): exponential backoff with a cap and a terminal state, no - prompt. The view stays visible marked stale (decision 6) until the staleness ceiling. +- **Transient** (network, 5xx, 429, and the 403 rate and quota reasons decision 6's table lists, + which Google says to handle identically to 429): exponential backoff with a cap and a terminal + state, no prompt. The view stays visible marked stale (decision 6) until the staleness ceiling. +- **Unreachable** (a transient condition that outlives decision 6's 24-hour ceiling): the events + are dropped, the binding moves to `unreachable`, and the offered action is **Retry**, with one + line about checking the network. It is deliberately not `needs_reconnect`: nothing says the + grant is broken, and a reconnect affordance here opens a browser that cannot reach Google either. - **Terminal** (`invalid_grant`, `invalid_client`, revoked or expired grant, a required scope no longer granted): the refresh loop stops at once, the binding moves to `needs_reconnect`, event - data is dropped at the ceiling, and a quiet, persistent "Reconnect Google Calendar" action - appears on the calendar surface *and* in settings. + data is dropped, and a quiet, persistent "Reconnect Google Calendar" action appears on the + calendar surface *and* in settings. -The reconnect action is never hidden behind the same state it repairs, and the failure never signs -the user out of Buzz, never deletes the channel mapping, and never shows a raw OAuth error string. -Failures are logged with the reason and never with a token or an authorization code; T12 asserts -that with a test. Reconnect reuses the connect flow and keeps the binding when `sub` matches; a -different `sub` is an account change and takes the explicit confirm from decision 2. +`needs_reconnect` is reserved for that terminal class and never entered from a network condition. +The reconnect action is never hidden behind the same state it repairs, and no failure signs the +user out of Buzz, deletes the channel mapping, or shows a raw OAuth error string. Failures are +logged with the reason and never with a token or an authorization code; T12 asserts that with a +test. Reconnect reuses the connect flow — including decision 1's full-union request and readback — +and keeps the binding when `sub` matches; a different `sub` is an account change and takes the +explicit confirm from decision 2. **Reason.** Rule 6 — a guard that hides the only recovery affordance is a functional failure, so the reconnect entry lives in two places, one of which does not depend on the broken surface rendering. Rule 4 — a terminal auth error must stop the loop, not retry forever against a grant that will never come back. Splitting transient from terminal is what stops a flaky network from -nagging the user to re-consent. The split also depends on the client's publishing status -(decision 1). Google's seven-day refresh-token expiry binds a client whose user type is External -and whose publishing status is Testing; decision 1's Internal choice already excludes it, so the -"In production, never Testing" rule is belt-and-braces here and load-bearing for the External -client the shipping-beyond-one-Workspace ticket would need. Left in that state, the terminal branch -would stop being an exception and become the normal weekly experience. +nagging the user to re-consent; splitting `unreachable` out of terminal is the same argument taken +to the end, because a state name is an instruction to the user and "reconnect" is the wrong +instruction for an outage. The split also depends on the client's publishing status (decision 1). +Google's seven-day refresh-token expiry binds a client whose user type is External and whose +publishing status is Testing; decision 1's Internal choice already excludes it, so the "In +production, never Testing" rule is belt-and-braces here and load-bearing for the External client +the shipping-beyond-one-Workspace ticket would need. Left in that state, the terminal branch would +stop being an exception and become the normal weekly experience. ### 9. What an agent may read or write From 1d15964fc1d2454b65092a895f47837c9fd62763 Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:59:52 -0700 Subject: [PATCH 05/11] docs: close ten authorization findings from the second Sol audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 1: model a grant as one per (Cloud project, Google account) rather than one per token, because Google revokes at project granularity. A same-sub re-authorization that comes up short now discards the new token locally and never calls revoke — the old rule would have taken the stored refresh token with it and stripped the read surface the user already had, on every device. Replace the "can never leave an account holding less" guarantee with the true, narrower one. Route every new-grant cleanup (insufficient scope, persist failure, identity-mismatch abort, declined account-change confirm) through one named "discard the new grant" operation with the same-sub exception, and add the connect pipeline's success terminus, which discards a matching pending revocation job in the same blob mutation that writes the binding. Decision 2: state where sub comes from (the id_token in the direct token-endpoint response, aud checked against this client id, nothing else) and constrain the command surface — opaque binding handles, no caller-supplied calendar or event ids, Rust re-deriving identity, community, mapping and accessRole per call, a native confirm to add a calendar. Keeping the token in Rust stopped the token leaving and let its authority leave instead. Decision 4: key the mapping by (pubkey, community relay id, channel id) — channel ids are relay-scoped NIP-29 group ids — and register the calendar module's reset in resetCommunityState(), per the AGENTS.md contract. Fence every in-flight response on the full tuple. Decision 5: make the disconnect one blob mutation (journal in, binding out) over the existing whole-map closure, and put the binding key, generation and cache-purge scope in the journal so a replay finishes the local cleanup before the remote call. Turn the tombstone into a bounded collection keyed by (project, sub, job id) with a cap, a deadline and a stated behavior at the cap. Decide the pending-job-versus-reconnect interaction. Sign-out enumerates every binding and job across every identity under one total deadline and names every unresolved account in the confirm. Decision 6: close the failure matrix on the operation — an event-level 404 removes that event only, and only a calendar-level request can establish access loss; an unknown 403 reason stays transient and ages into unreachable. A 401 on a resource call triggers one generation-fenced forced refresh and one replay, so a routine token expiry is no longer a re-consent prompt. Key cache rows by the full identity/community/generation tuple. Decision 8: split invalid_client out of the terminal class into app_error, which offers no reconnect, because no amount of reconnecting repairs a misconfigured OAuth client. Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 375 ++++++++++++++---- 1 file changed, 293 insertions(+), 82 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index 6d960c80230..4e547c7888f 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -68,8 +68,8 @@ read-only surface is produced by the readback, not by a smaller request: `calendar.events.readonly` / `calendar.events`; - edit affordances need `calendar.events` **and** decision 3's `accessRole` test; - anything short of the read set is a *connect failure*, not a degraded surface: the credential is - not persisted, the grant just issued is revoked (see "When connect fails"), and the user is told - which permission the calendar view requires. + not persisted, the new grant is discarded by the single operation "When connect fails" defines, + and the user is told which permission the calendar view requires. **No incremental authorization.** Google's OAuth 2.0 for Mobile & Desktop Apps guide states that incremental authorization is not supported for installed apps or devices, and this decision pins @@ -77,9 +77,36 @@ the client to type Desktop app, so `include_granted_scopes=true` is never sent. set has to change later — the user unchecked write at consent and now wants to schedule — Buzz runs a **complete new authorization for the whole union above**, and replaces the stored credential only after the new token response's `scope` string is verified to contain every scope the current -surface depends on. If it does not, the exchange is abandoned, the newly issued token is revoked, -the existing credential is left untouched, and the surface reports that the extra permission was -not granted. A re-authorization can never leave an account holding less than it held before. +surface depends on. + +**A grant is one per (Cloud project, Google account), not one per token.** That is the shape +Google's revocation endpoint works on: revoking any token removes every scope the account granted +to the *project* and invalidates the tokens of every client registered under it. Two token +responses for the same account are two views of one grant, not two grants, so "revoke the token we +just issued" and "revoke the credential we already hold" are the same operation. The short-scope +branch of a re-authorization is therefore decided by `sub` before anything is revoked: + +- **Same `sub` as the stored binding** — the ordinary case, a user upgrading their own connection. + The new token is dropped in memory and **never sent to the revocation endpoint**. Buzz + re-validates the existing credential with one `calendarList` call, reports the scopes that + credential actually holds, and says the extra permission was not granted. Google's grants are + additive, so a consent screen on which the user granted less does not withdraw what the account + already granted; if the stored credential has stopped working anyway, that is decision 8's + terminal path and nothing here special-cases it. +- **Different `sub`** — not a re-authorization at all but decision 2's account change. It takes the + explicit confirm, and a decline or a short scope set runs "discard the new grant" below, which + may revoke, because that token belongs to a different account's grant. + +We never revoke on a same-`sub` re-authorization. That is the guarantee, and it is narrower than +the one an earlier draft of this memo made — "a re-authorization can never leave an account holding +less than it held before" — which was false: honoring it by revoking the short token would have +taken the stored refresh token with it and stripped the read surface the teacher already had, on +every device she uses. + +T12 names a test with two token responses for one grant: a stored binding for `sub` S holding the +full union, a re-authorization for S returning only `calendar.events`, and the assertions that no +request reaches the revocation endpoint, that the stored refresh token is byte-identical +afterwards, and that the surface still reports the original scopes. **A dedicated Google Cloud project.** The OAuth client lives in a Google Cloud project that contains nothing but this client, owned by the same Workspace that owns the business calendar @@ -126,8 +153,8 @@ callback, all of it is validated in this order before any token exchange: live flow: `import_identity` is a live command (`desktop/src-tauri/src/commands/identity.rs:337`) and the codebase already serializes identity mutation against itself (`identity.rs:229,355,477`). A mismatch at either point aborts the - flow: nothing is written under the new identity, and a token already issued by the exchange is - revoked before the failure is reported. + flow: nothing is written under the new identity, and a token already issued by the exchange goes + through "discard the new grant" below before the failure is reported. Without step 3 the credential for pubkey A's Google account would be written under pubkey B, B's surface would render A's calendar, and decision 2's "connecting a different Google account requires @@ -138,8 +165,9 @@ replaying a valid `state`, a callback after the deadline, and an identity switch browser opening and the callback. **When connect fails.** Connect-time failure is its own axis, not one of decision 6's post-connect -states. Connect is a pipeline, and every stage has a failure; the list is exhaustive on purpose, -because the dangerous states are the late ones where a Google grant exists and we hold nothing: +states. Connect is a pipeline, and every stage has a failure; the list runs to the success +terminus and is exhaustive on purpose, because the dangerous states are the late ones where a +Google grant exists and we hold nothing: 0. **Preflight — before the browser opens.** The loopback listener binds its port, and `SecretStore` is probed with a write-and-delete of a scratch key. A bind failure or a locked or @@ -163,25 +191,44 @@ because the dangerous states are the late ones where a Google grant exists and w connecting", with Try again, and one sentence saying Buzz may appear in their Google account's third-party access list until they retry or remove it there. Retry is a fresh transaction, never a reuse of the spent one. -3. **Scope-verified / insufficient.** The readback rule above. An insufficient grant revokes the - token just issued and persists nothing. +3. **Scope-verified / insufficient.** The readback rule above. An insufficient grant persists + nothing and runs "discard the new grant" below. 4. **Persisted / persist failed.** The exchange succeeded, so a live grant exists on the user's account and we hold its refresh token. `SecretStore::store` returns `Result<(), String>` (`desktop/src-tauri/src/secret_store.rs:729`), and a locked or unavailable keychain is a real return value even after the preflight passed. On `Err` the app does not fall back to plaintext - and does not report a bare failure: it revokes the refresh token at Google, and reports - "Couldn't finish connecting" once the revocation returns 200. If the revocation also fails, it - writes decision 5's revocation tombstone — a separate, much smaller record, attempted even - though the credential write failed — and the tombstone's retry schedule finishes the job. If the - tombstone cannot be written either, the failure message says in words that a Google grant for - Buzz may still exist, and links to the user's Google account permissions page. A grant with no - local record and no durable instruction to remove it is exactly Review-Proven Rule 1's - catch-with-no-durable-record, so it is the one outcome this list refuses to leave silent. - -T12 names a test for an exchange failure (no credential, no partial record, no tombstone), for a -persist failure after a successful exchange (the issued token is revoked and the failure is -reported), and for a persist failure whose revocation also fails (a tombstone survives and the -next launch retries it). + and does not report a bare failure: it runs "discard the new grant" and reports "Couldn't finish + connecting" together with whatever that operation resolved to. +5. **Connected.** The binding is written and, in the *same* blob mutation, any revocation job this + installation still has pending for the same (project, `sub`) is discarded (decision 5). + +**"Discard the new grant" — one operation, named once.** Stage 3, stage 4, the identity-mismatch +abort above and a declined account-change confirm (decision 2) all end holding a token the exchange +just issued and no right to keep it. They run the same ordered steps, stated here rather than +re-derived at four call sites that can drift apart: + +1. **The same-`sub` exception.** If the new token's `sub` matches a binding this installation + already holds for this Cloud project, nothing is revoked: the token is dropped in memory and the + operation is done. Revoking it would revoke that binding too — one grant per (project, account). +2. Otherwise post the refresh token to Google's revocation endpoint. HTTP 200, or a terminal + `invalid_token` meaning the grant is already gone, finishes the operation. +3. On any other outcome, write a decision 5 revocation job for that (project, `sub`). Its retry + schedule finishes what the network could not, and the user is told a revocation is pending. +4. If the journal write also fails, the failure message says in words that a Google grant for Buzz + may still exist, and links to the user's Google account permissions page. + +A grant with no local record and no durable instruction to remove it is exactly Review-Proven Rule +1's catch-with-no-durable-record, so it is the one outcome this list refuses to leave silent — and +it is refused in one place instead of once per stage. + +T12 names a test per path into that operation: an exchange failure (no credential, no partial +record, no revocation job); an insufficient scope set with the revocation endpoint reachable (the +token is revoked, nothing persists); an insufficient scope set with the network down (a revocation +job survives and the next launch retries it); a persist failure after a successful exchange (the +issued token is revoked and the failure is reported); a persist failure whose revocation also fails +(a job survives and the next launch retries it); an identity switch between the exchange and the +persist (the token is revoked and nothing is written under either identity); and a declined +account-change confirm (the token is revoked and the previous binding is untouched). None of these states is "not shared with your account" (decisions 4 and 6): that message means a connected @@ -217,17 +264,52 @@ blob (the `BLOB_KEY` username `secrets`, `desktop/src-tauri/src/secret_store.rs: service name is not a constant in that file but comes from `keyring_service()`, `desktop/src-tauri/src/app_state_keyring.rs:9-23`, which returns `buzz-desktop` for release builds and a `buzz-desktop-dev*` service otherwise), so this costs no extra keychain prompt. Decision 5's -revocation tombstone is a *second, separate key* in that same blob; it is deliberately not a field +revocation journal is a *second, separate key* in that same blob; it is deliberately not a field of this record, because it has to outlive it. Token exchange, refresh and every Google API call happen in Rust. The webview receives a redacted status struct only: connected, email, granted scopes, expiry, state. Connecting a different Google account requires an explicit confirm and revokes the previous grant first (decision 5). The pubkey → Google-account mapping is never published to the relay. +**Where `sub` comes from.** The `sub` is read from the `id_token` in the token-endpoint response +Buzz receives directly from Google over TLS, and from nowhere else: never from a UserInfo call, +never from a value that passed through the webview, never from the loopback callback's query +string. Because that channel is direct and intermediary-free, Google's own OpenID Connect guidance +lets an app use the claims of a token received that way without full signature validation. Buzz +still checks that the `aud` claim equals this client id, and that check is not ceremony: it is what +stops a token minted for some other client from driving the account-change comparison and being +persisted as the binding identity. If a later ticket ever sources `sub` from anywhere but that +direct response, full ID-token validation — signature against the published JWKS, `iss`, `aud`, +`exp` — becomes required at that point, and the ticket that moves it owns that work. + +**The command surface is part of this boundary.** Holding the token in Rust stops the *token* from +leaving the process; on its own it does not stop the token's *authority* from leaving, because the +renderer can still invoke the commands. So the calendar commands are constrained here: + +- Every command takes an **opaque binding handle** minted in Rust — a random id valid only for the + current active identity, the current community and the current binding generation. No command + takes a caller-supplied calendar id, and no command enumerates calendars. +- An event is addressed by an event handle drawn from the rows Rust itself delivered for the + current window, never by a raw Google event id supplied by the caller. +- On every call Rust re-derives from its own state, not from arguments: the active identity pubkey, + the current community, the channel-to-calendar mapping (decision 4), and the `accessRole` from + the last `calendarList` answer (decision 3). Any mismatch rejects the call. +- Adding a calendar to the mapping goes through a native confirmation outside the webview, showing + the calendar summary and the Google account, so a renderer cannot widen the surface silently. + +T12 names handler-level tests: a handle whose calendar is no longer in the current mapping is +rejected, and so is a request that carries a raw calendar id at all; a handle minted under a +previous binding generation is rejected; a handle minted under another identity is rejected; and a +handle minted in community A is rejected after a switch to B. + **Reason.** `sub` is stable; a Workspace email can be renamed or reassigned to a different human, and a binding keyed on email would silently follow the address to the new person. Keeping tokens out of the webview matters because the CSP's `connect-src` already allows `https:` — a token in -the renderer is one XSS away from any host. Not publishing the mapping keeps the workspace +the renderer is one XSS away from any host. That same `https:` allowance is why the command +constraints above are load-bearing rather than decorative: an XSS that cannot read the refresh +token can still call a command, and a command that accepted a calendar or event id would let it +enumerate and edit every calendar the account-wide token reaches — personal calendars this contract +never discusses included — and exfiltrate the results through the same allowance. Not publishing the mapping keeps the workspace identity graph off the relay, where the operator and every channel member would otherwise see who holds which Google account. @@ -271,8 +353,11 @@ surface derives from Google's answer, including the answers Google has not inven ### 4. Which channels show the calendar, and who chooses **Decision.** The calendar surface is opt-in per channel, and **in v1 each user sets the mapping -locally**: it is a per-identity desktop setting in the app-data dir next to the archive DB, and -nothing carries one person's choice to anyone else's installation. The channel admin (kind:39001, +locally**: it is a per-identity, per-community desktop setting in the app-data dir next to the +archive DB, and nothing carries one person's choice to anyone else's installation. Its key is the +tuple (pubkey hex, canonical community relay id, channel id), never the channel id alone — channel +ids are relay-scoped NIP-29 group ids, so one pubkey active on communities A and B would otherwise +match in B a row written for A and draw the calendar in the wrong community's channel. The channel admin (kind:39001, with membership at kind:39002 — `crates/buzz-core/src/kind.rs:424-426`) decides which calendar id the channel *should* use and conveys that choice out of band — a pinned message, the channel topic, onboarding — so in v1 it is a convention the app does not enforce. Admin-owned mapping becomes @@ -285,6 +370,20 @@ account", never someone else's events. That message is about the ACL and nothing could not connect in the first place gets decision 1's connect-time state instead, and a member whose calendar simply could not be reached gets decision 6's unreachable state. +**A community switch is a removal path, and the calendar module registers for it.** `AGENTS.md` +"Community Switching" makes this a repository contract: switching remounts the React subtree but +leaves module-level singletons alive, so every community-scoped singleton needs its reset wired +into `resetCommunityState()` (`desktop/src/features/communities/useCommunityInit.ts:59`) in the +same change that introduces it — the comments there record a shipped defect from getting this +wrong. The calendar module registers its reset in that inventory, clearing the in-memory token +cache, the refresh poll timer, the pending-request map and the mapping cache. In-flight work is +fenced the way decision 5 fences a refresh: every list, refresh and edit response re-checks the +full tuple — pubkey, community, binding generation, `sub`, calendar id — before it is persisted and +again before it is delivered to the view, so a request started in community A that resolves after a +switch to B writes nothing and renders nothing (Rule 2). T12 names a test for exactly that delayed +A response landing after an A→B switch, and one for the same channel id existing in two +communities. + **Reason.** Making the mapping powerless is what lets it be stored loosely. If a wrong or stale mapping could expose event data, it would need the same durability and audit as the ACL itself; because every read is made with the viewer's own token, the worst outcome of a bad mapping is an @@ -295,44 +394,109 @@ the first place. ### 5. Disconnect behavior -**Decision.** "Disconnect" is one user action with an ordered, resumable effect. It works on two -*separate* keychain records, and the separation is the whole point: +**Decision.** "Disconnect" is one user action, one durable write, and a resumable remainder. It +works on two *separate* records inside the same `SecretStore` blob, and the separation is the whole +point: - the **binding record** of decision 2 — refresh token, access token, expiry, granted scopes, `sub`, email — under the pubkey-namespaced key; -- the **revocation tombstone** — refresh token, `sub`, attempt count, first-attempt time — under - its own key in the same `SecretStore` blob, and never touched by the code paths that delete a - binding. +- the **revocation journal** — a bounded collection of pending revocation jobs, under its own key + in the same blob, and never touched by the code paths that delete a binding. + +A journal entry is keyed by (Cloud project, Google `sub`, job id) and carries: the refresh token, +the display email, the pubkey-namespaced binding key it came from, the binding generation, the +cache-purge scope (the pubkey, community and calendar ids whose cache rows this binding produced — +decision 6's key tuple), the attempt count, the first-attempt time, the next-attempt time, and a +seven-day deadline. It carries the binding key and the purge scope precisely so a replay can finish +the *local* cleanup an interrupted disconnect started, before it touches the network. Rule 4 bounds +the collection: a second Disconnect of the same account supersedes the entry it finds rather than +adding one, so there is one live entry per Google account and at most sixteen entries; at the cap a +new Disconnect is refused with "resolve the pending Google revocations first", because dropping a +durable record is Rule 1's defect and growing without a bound is Rule 4's. The ordered effect: -1. Write the revocation tombstone (one persist, not three). -2. Delete the binding record (`SecretStore::delete`, - `desktop/src-tauri/src/secret_store.rs:901`), drop the in-memory token cache, and fence any - in-flight refresh by generation so a late response cannot rewrite a deleted record. -3. Purge the cached events for that binding (decision 6). -4. Call Google's revocation endpoint with the refresh token read from the tombstone. -5. Delete the tombstone **only** on HTTP 200, or on a terminal `invalid_token` response, which - means the grant is already gone. -6. Leave the channel mapping in place, so reconnecting returns to the same view. - -Because the tombstone is written first and holds everything the retry needs, every prefix of that -sequence is consistent: a crash, an offline machine or a 5xx leaves no usable local credential and -a durable instruction to finish the revocation. Retries run at next launch and on network recovery -with backoff and a capped attempt count; at the cap the tombstone is **kept**, not dropped, and -settings shows one line — "Google Calendar revocation is still pending" — with Retry and a link to -the user's Google permissions page. Deleting the record the retry depends on is exactly the PR -#6269 defect. T12 names a test for a transient revocation failure that resumes across a restart. +1. **One blob mutation** inserts the journal entry and removes the binding record together. + `SecretStore` mutates its blob through a closure that receives the whole map + (`desktop/src-tauri/src/secret_store.rs:395`), so this is one keychain write under one lock, not + a `store` (`secret_store.rs:729`) followed by a `delete` (`secret_store.rs:901`). T12 adds the + one method that exposes that closure to callers. +2. **Local cleanup, driven by the entry**: drop the in-memory token cache, fence any in-flight + refresh by the recorded generation so a late response cannot rewrite a deleted record, and purge + the cached events named by the entry's purge scope (decision 6). Every step is idempotent and is + re-run from the entry at replay. +3. **Call Google's revocation endpoint** with the refresh token read from the entry. +4. **Remove the entry** — a second blob mutation — **only** on HTTP 200, or on a terminal + `invalid_token` response, which means the grant is already gone. + +The channel mapping is left in place throughout, so reconnecting returns to the same view. + +That sequence has exactly three durable boundaries, and the state at each one is stated rather than +claimed in general: + +- **Before the mutation.** Nothing has happened. The credential still works and Disconnect can be + pressed again. +- **After the mutation.** There is no usable local credential, and there is a journal entry naming + the binding key, the generation, the purge scope and the token to revoke. Whatever step 2 had + finished, the replay finishes the rest before it calls the network, so no crash can leave a live + credential or an unpurged event cache behind a Disconnect the user already pressed. The earlier + draft's "write the tombstone, then delete the binding" ordering could leave exactly that, and its + tombstone carried neither the binding key nor a purge scope, so the replay could not have + finished the local half. +- **After the revocation returns 200.** The entry is removed. A crash before that removal replays + the revocation, which answers `invalid_token`, which is also terminal — so the replay converges + instead of looping. + +Retries run at next launch and on network recovery with backoff and a capped attempt count; at the +cap, or at the seven-day deadline, the entry is **kept**, not dropped, and settings shows one line +— "Google Calendar revocation is still pending" — naming the account, with Retry and a link to the +user's Google permissions page. Deleting the record the retry depends on is exactly the PR #6269 +defect. + +T12 names a failpoint test at every durable boundary: a crash before the mutation, a crash +immediately after it, a crash part-way through the cache purge, a crash after the purge and before +the revocation call, a 5xx revocation that resumes across a restart, and a crash between a 200 +response and the entry removal. + +**A pending job never revokes a newer grant on this installation.** Disconnect while offline, +change your mind, press Connect, consent again: the connect pipeline's final stage (decision 1, +stage 5) discards any journal entry for the same (project, `sub`) in the same blob mutation that +writes the new binding. Executing that entry could not do what the user asked for anyway — the old +refresh token and the new one are one grant, so revoking the old one would kill the connection just +made and hand the user `needs_reconnect` with no visible cause. The abandonment is stated, not +silent: the old refresh token is left to expire on Google's own schedule, and settings records that +a pending revocation for that account was superseded by a reconnect. A journal entry for a +*different* `sub` is untouched and still runs. + +Across two installations we cannot do this, and we do not pretend to: laptop A's journal is in +laptop A's keychain, so if desktop B reconnects the same account first, laptop A's replay revokes +the grant B is using. That is the cross-device effect the confirm text below already names, +arriving later than the user expected. B does not show a bare auth error for it: the +`needs_reconnect` message names the two things it can honestly be — a disconnect from another +device, or the user removing Buzz at their Google account page — and does not guess between them. + +T12 names a test for disconnect-offline, then reconnect, then restart: no request reaches the +revocation endpoint, the new binding survives, and the superseded entry is gone. It names the +two-account variant too: a pending job for account A still runs after a reconnect of account B. **Sign-out.** Full sign-out is a wipe, not a disconnect, and it needs a stated policy rather than an accident: the boot reset renames the app-data dir and calls `delete_all_with_legacy()` then `verify_fully_wiped()` on the keychain (`desktop/src-tauri/src/reset.rs:273,315`), which removes -the whole blob — the tombstone with it, and the token the revocation needs. So sign-out attempts -the revocation once before the wipe, bounded to a few seconds with no retry loop. If it succeeds, -the wipe proceeds normally. If it does not, the confirm says so in words — "Your Google Calendar -grant could not be revoked and stays active until you remove Buzz at your Google account page", -with the link — and the wipe proceeds anyway, because a sign-out that refuses to sign out is -worse than a disclosed live grant. T12 names a test for sign-out with a revocation pending. +the whole blob — every journal entry with it, and every token a revocation would need. The wipe is +the one path with no second chance, and one revocation attempt does not cover it: an installation +holds a binding *per identity*, and importing another identity is a live command +(`desktop/src-tauri/src/commands/identity.rs:337`), so two pubkeys each connected to their own +Google account is an ordinary state, not an exotic one. Sign-out therefore **enumerates every +binding and every pending journal entry in the blob, across every identity**, and attempts each +one under **one total deadline** of a few seconds — one budget for the whole set, no retry loop, +and no per-account budget that a slow first account can spend. Whatever is unresolved when that +deadline expires is named: the confirm lists each account by email — "these Google Calendar grants +could not be revoked and stay active until you remove Buzz at your Google account page" — with the +link, and the wipe proceeds anyway, because a sign-out that refuses to sign out is worse than a +disclosed live grant. T12 names a test with two identities, two Google accounts and overlapping +failures across a restart: both disconnects fail offline, both entries survive the restart and both +retry; then a sign-out whose first revocation succeeds and whose second fails names exactly the +second account in the confirm and still wipes. **This is not a per-device action, and its blast radius is the Cloud project.** Google's revocation endpoint removes every scope the user previously granted to the OAuth client's *Cloud @@ -350,22 +514,28 @@ cause instead of showing a bare auth error. **Reason.** `AGENTS.md` Review-Proven Rule 1 — a caught failure leaves a durable retry record or propagates; a journal stored *inside* the record the operation deletes is not a journal, which is -why the tombstone is its own key with its own deletion rule. Rule 5 — one user action is one -atomic persist, ordered so every prefix is consistent: a crash after step 2 leaves a -revoked-or-pending grant, no local token and a live instruction to finish, which is safe. Rule 2 -— the generation fence stops a completing refresh from resurrecting a deleted binding. +why the journal is its own key with its own deletion rule. Rule 5 — one user action is one atomic +persist, taken literally rather than approximated: the two writes that used to be steps 1 and 2 are +one blob mutation, so the torn state between them — a usable credential, an unpurged cache, and a +journal entry that would revoke the grant at the next launch — has no prefix to occur in. Rule 4 — +the journal is a capped collection with a deadline and a stated behavior at the cap, not an +unbounded queue. Rule 2 — the generation fence stops a completing refresh from resurrecting a +deleted binding. Per-device revocation would need a distinct OAuth client or a distinct Google account per machine; both are worse than the cross-device effect, and a "disconnect" that quietly leaves a live grant on a machine the user no longer has is the worst option of the three. ### 6. Cached event data on disconnect and on membership loss -**Decision.** Cached events are a bounded render cache: per binding, keyed by Google `sub` and -calendar id, in its own SQLite file in the nest — not mixed into the relay archive tables, whose -rows carry a relay access proof that calendar rows do not have -(`desktop/src-tauri/src/archive/mod.rs:1-19`). The cache holds only the expansion window the view -needs (window and recurrence expansion are T12a's to size) under a hard row cap, and every row -carries the timestamp of the refresh that produced it. +**Decision.** Cached events are a bounded render cache in its own SQLite file in the nest — not +mixed into the relay archive tables, whose rows carry a relay access proof that calendar rows do +not have (`desktop/src-tauri/src/archive/mod.rs:1-19`). A row's key is the full tuple (pubkey hex, +canonical community relay id, binding generation, Google `sub`, calendar id, event id), for +decision 4's reason: identity, community and binding generation all change under a running app, and +a key missing any of them lets a response written for one of them be read back under another. That +tuple is re-checked before every persist and again before every delivery to the view. The cache +holds only the expansion window the view needs (window and recurrence expansion are T12a's to size) +under a hard row cap, and every row carries the timestamp of the refresh that produced it. - **On disconnect:** purged, as part of decision 5. - **On loss of Buzz channel membership:** the channel's mapping row is dropped and the surface @@ -387,14 +557,42 @@ produced it: | 403 `userRateLimitExceeded`, `rateLimitExceeded`, `quotaExceeded` | transient (decision 8) | keep the cache, back off; identical handling to 429 | | 403 `forbiddenForNonOrganizer` | write-authorization | fails that write only; the read path and the cache are untouched | | 403 `insufficientPermissions` on a write | write-authorization | edit affordances drop to read-only; if the scope readback shows `calendar.events` absent, decision 1's re-authorization is offered | -| 403 with an access reason, or 404, on a **read** of the calendar or its events, still failing after one backed-off retry | access loss | purge that calendar's cached rows; surface "no longer shared with your account" | -| 401, or refresh returning `invalid_grant` | terminal auth (decision 8) | `needs_reconnect` | +| 404 on an **event-level** request (get, patch or delete of one event id) | missing event | drop that one cached row and refresh the window; never access loss, never a message about sharing | +| 403 with an access reason, or 404, on a **calendar-level** read (`calendars.get`, `calendarList.get`, or a list of that calendar's events), still failing after one backed-off retry | access loss | purge that calendar's cached rows; surface "no longer shared with your account" | +| 401 on a resource call | expiry until proven otherwise | one generation-fenced forced refresh and one replay of that call; the refresh response classifies, never the 401 | +| refresh returning `invalid_grant`, or a 401 on the replay after a refresh that succeeded | terminal auth (decision 8) | `needs_reconnect` | +| refresh returning `invalid_client` | app error (decision 8) | `app_error`; no reconnect affordance, because a reconnect cannot repair it | | network failure, 5xx, 429 | transient (decision 8) | keep the cache, back off | -An unrecognized `reason` on a read is treated as transient and retried; only a second failure -after backoff can be access loss. Erring toward transient is deliberate and asymmetric: a wrong -transient call costs one stale poll interval, while a wrong access-loss call purges a cache and -sends a teacher to an administrator to hunt an ACL that was never wrong. T12 names a test per row. +**The matrix closes on the operation, not only the status.** Google documents 404 for two +different things: a resource that never existed, and a calendar the user cannot access. An event +someone deleted in Google answers 404 to a read of its cached id, and answers 404 again to the +backed-off retry, because a deleted event stays deleted. Classified on status alone that satisfies +the access-loss condition, purges the whole calendar, and sends a teacher to an administrator to +hunt an ACL that is correct — the support call decision 6 already removed from the rate-limit row, +one row further down. So only a **calendar-level** request can establish access loss; an +event-level 404 removes that event and nothing else. + +**A 401 is an expiry until a refresh says otherwise.** Access tokens last about an hour and +decision 7's poll runs every five minutes, so a token expiring mid-call is ordinary operation, not +an authorization event. A 401 on a resource call therefore triggers exactly one forced refresh — +fenced by the binding generation, and single-flighted so a burst of concurrent 401s produces one +refresh and not one each — and then one replay of the call. Only the refresh response can be +terminal, which is Google's own instruction for this status: get a new access token with the +refresh token, and send the user through the OAuth flow only if that fails. A 401 on the replay +after a refresh that succeeded is terminal, because nothing further can repair it. + +An unrecognized `reason` on a read stays transient however often it repeats. It never ages into +access loss; it ages into `unreachable` at the staleness ceiling, like any other condition we +cannot classify. Erring toward transient is deliberate and asymmetric: a wrong transient call costs +one stale poll interval, while a wrong access-loss call purges a cache and sends a teacher to an +administrator to hunt an ACL that was never wrong. + +T12 names a test per row, and three more that the rows alone would not force: a deleted event that +answers 404 twice (only that row disappears, no purge and no sharing message), an unknown 403 +reason that repeats until the staleness ceiling (`unreachable`, never "no longer shared"), and the +expiry-mid-call race — one 401, one refresh, one successful replay, no state change and no +Reconnect prompt — plus an assertion that five concurrent 401s produce exactly one refresh. **Absence from `calendarList` is not evidence of anything.** The list call hides calendars for two ordinary reasons: `showHidden` defaults to false, and `maxResults` defaults to 100 entries with @@ -436,7 +634,7 @@ staff room opening Buzz at five to nine is a load spike, not an ACL change. ### 8. Refresh-token failure UX -**Decision.** Three states, handled differently. +**Decision.** Four states, handled differently. - **Transient** (network, 5xx, 429, and the 403 rate and quota reasons decision 6's table lists, which Google says to handle identically to 429): exponential backoff with a cap and a terminal @@ -445,18 +643,26 @@ staff room opening Buzz at five to nine is a load spike, not an ACL change. are dropped, the binding moves to `unreachable`, and the offered action is **Retry**, with one line about checking the network. It is deliberately not `needs_reconnect`: nothing says the grant is broken, and a reconnect affordance here opens a browser that cannot reach Google either. -- **Terminal** (`invalid_grant`, `invalid_client`, revoked or expired grant, a required scope no - longer granted): the refresh loop stops at once, the binding moves to `needs_reconnect`, event - data is dropped, and a quiet, persistent "Reconnect Google Calendar" action appears on the - calendar surface *and* in settings. - -`needs_reconnect` is reserved for that terminal class and never entered from a network condition. +- **Terminal** (`invalid_grant`, a revoked or expired grant, a required scope no longer granted, + or a 401 that survives decision 6's one forced refresh): the refresh loop stops at once, the + binding moves to `needs_reconnect`, event data is dropped, and a quiet, persistent "Reconnect + Google Calendar" action appears on the calendar surface *and* in settings. +- **App error** (`invalid_client`, and any other response saying Buzz's own OAuth client is wrong): + the refresh loop stops, the binding moves to `app_error`, and the state line reads "Buzz's Google + Calendar configuration is not valid". There is **no Reconnect action and no Retry**, because + neither repairs it — the client id, its secret or its Cloud project is wrong, and only a new + build or a console change fixes that. The reason is logged. The data is handled as in + `unreachable`: the last-good view stays visible marked stale with edits disabled, and is dropped + at the 24-hour ceiling. + +`needs_reconnect` is reserved for that terminal class. It is never entered from a network +condition, never from a routine token expiry, and never from an app-configuration failure. The reconnect action is never hidden behind the same state it repairs, and no failure signs the user out of Buzz, deletes the channel mapping, or shows a raw OAuth error string. Failures are logged with the reason and never with a token or an authorization code; T12 asserts that with a test. Reconnect reuses the connect flow — including decision 1's full-union request and readback — -and keeps the binding when `sub` matches; a different `sub` is an account change and takes the -explicit confirm from decision 2. +and keeps the binding when `sub` matches, without ever revoking, per decision 1's same-`sub` rule; +a different `sub` is an account change and takes the explicit confirm from decision 2. **Reason.** Rule 6 — a guard that hides the only recovery affordance is a functional failure, so the reconnect entry lives in two places, one of which does not depend on the broken surface @@ -464,7 +670,12 @@ rendering. Rule 4 — a terminal auth error must stop the loop, not retry foreve that will never come back. Splitting transient from terminal is what stops a flaky network from nagging the user to re-consent; splitting `unreachable` out of terminal is the same argument taken to the end, because a state name is an instruction to the user and "reconnect" is the wrong -instruction for an outage. The split also depends on the client's publishing status (decision 1). +instruction for an outage. Splitting `app_error` out is that argument once more: "reconnect" is a +false instruction for a misconfigured client, because the user can consent all day and the next +refresh fails identically. Keeping a bare 401 out of the terminal class entirely is the same +concern at the other end of the scale — an hourly token lifetime against a five-minute poll makes +the expiry race routine, and a terminal branch that fires on it would turn re-consent into normal +operation. The split also depends on the client's publishing status (decision 1). Google's seven-day refresh-token expiry binds a client whose user type is External and whose publishing status is Testing; decision 1's Internal choice already excludes it, so the "In production, never Testing" rule is belt-and-braces here and load-bearing for the External client From 3c0b143950b1fab67519f0031356c39f49b3dd61 Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:30:02 -0700 Subject: [PATCH 06/11] docs: close Sol pass-3 authorization findings Seven kept findings from the third Sol audit of the T11 calendar authorization contract, all fixed, none refuted. - Failed and declined connects no longer revoke. "Discard the new grant" drops the token in memory, posts nothing to the revocation endpoint and discloses the residual grant in words. The same-`sub` exception is deleted: it asked whether this installation holds a record, when the blast radius turns on whether that Google account holds a grant anywhere. Revocation now happens only on Disconnect, the confirmed half of an account change, and sign-out. - `binding_generation` is defined and persisted: a 128-bit CSPRNG field of the binding record, minted on credential replacement, unchanged by refresh and restart, carried by journal entries, cache rows and command handles, and tested across a real store reopen. - `events.list` on the mapped calendar becomes the authoritative probe and the only call that can establish access loss. `calendars.get` is dropped, since none of its five scopes is requested. Decision 3's `accessRole` moves to the `events.list` response, because Google no longer inserts a shared calendar into the recipient's CalendarList. The matrix gains rows for a read `insufficientPermissions`, a CalendarList 404, and a bounded-out request. - `propose_calendar_mapping` is named as the one command that accepts a raw calendar id, returning `confirmed` / `not_confirmed` and nothing else, with the summary shown only in the OS-native confirmation. - Decision 1 states shared client limits: connect, idle, per-request and per-operation deadlines, a response byte cap, page and item bounds, rejection of a repeated `nextPageToken`, and cancellation points. - Connect is single-flight per identity; a second Connect supersedes the live transaction, so a superseded callback carries an unknown `state`. - The union-at-connect scope choice now carries the read-only-first alternative, its cost and why it lost, plus the readback-equality test. Adds a Deviations section carrying the four already-named deviations forward and naming six new ones from this round. Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 440 ++++++++++++++---- 1 file changed, 351 insertions(+), 89 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index 4e547c7888f..d28d005bf3c 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -64,12 +64,40 @@ Never requested: `https://www.googleapis.com/auth/calendar` (full calendar manag the surface trusts.** Google's granular consent lets a user grant part of what was asked, so the read-only surface is produced by the readback, not by a smaller request: -- the read surface needs `calendar.calendarlist.readonly` **and** at least one of - `calendar.events.readonly` / `calendar.events`; +- the read surface needs **at least one of `calendar.events.readonly` / `calendar.events`**, and + nothing else. That is what `events.list` requires, and `events.list` is the only probe this + contract reads a calendar with (decisions 3 and 6); +- `calendar.calendarlist.readonly` is still requested, and is still verified before a credential + *replacement*, but it is **not** part of the read set: it feeds the deferred picker and the "this + calendar is not in your list" diagnostic, neither of which v1 ships or depends on. A user who + unchecks it at the consent screen connects, and the surface is whole, because decision 4's + mapping arrives through decision 2's proposal command and not through a list; - edit affordances need `calendar.events` **and** decision 3's `accessRole` test; - anything short of the read set is a *connect failure*, not a degraded surface: the credential is not persisted, the new grant is discarded by the single operation "When connect fails" defines, - and the user is told which permission the calendar view requires. + and the user is told which permission the calendar view requires; +- the scope set written to the binding record is **exactly** the readback string, parsed and stored + verbatim. It is never widened locally to the set that was *requested*, and no code path adds a + scope to a stored binding without a token response that justifies it. + +**Why the union at connect, and what the read-only-first alternative costs.** The alternative was +considered: ask at Connect for only `openid`, `userinfo.email`, `calendar.calendarlist.readonly` +and `calendar.events.readonly`, and route write authority through a later authorization. It is the +smaller default privilege, and its cost is stated here rather than waved off. Because incremental +authorization is unavailable to installed apps (below), the upgrade is a **complete second +consent** for the whole union, so every teacher who schedules pays a full re-authorization the +first time she drags a class, on a client that cannot merge the two grants. It lost on three +counts. The teacher who schedules is the ordinary user of this surface, not the exception. Granular +consent already hands the read-only user the smaller grant without a second request — the read set +does not contain `calendar.events`, so unchecking write at the consent screen still connects. And a +second full consent is the event decision 8 works hardest to keep rare: a user who is asked for +Google permissions twice reads the second prompt as a defect, not as a privilege boundary. What the +union costs is real and is not hidden: `calendar.events` authorizes editing events on every +calendar the account can write, personal calendars this contract never discusses included, which is +exactly why decision 2's command constraints are load-bearing rather than decorative. T12 asserts +the half that holds either way: the persisted scope set equals the readback string exactly, for a +full grant and for a partial one, and no test may observe a stored scope that no token response +returned. **No incremental authorization.** Google's OAuth 2.0 for Mobile & Desktop Apps guide states that incremental authorization is not supported for installed apps or devices, and this decision pins @@ -88,20 +116,26 @@ branch of a re-authorization is therefore decided by `sub` before anything is re - **Same `sub` as the stored binding** — the ordinary case, a user upgrading their own connection. The new token is dropped in memory and **never sent to the revocation endpoint**. Buzz - re-validates the existing credential with one `calendarList` call, reports the scopes that - credential actually holds, and says the extra permission was not granted. Google's grants are - additive, so a consent screen on which the user granted less does not withdraw what the account - already granted; if the stored credential has stopped working anyway, that is decision 8's - terminal path and nothing here special-cases it. + re-validates the existing credential with one `events.list` call on the mapped calendar + (decision 3's authoritative probe), reports the scopes that credential actually holds, and says + the extra permission was not granted. Google's grants are additive, so a consent screen on which + the user granted less does not withdraw what the account already granted; if the stored + credential has stopped working anyway, that is decision 8's terminal path and nothing here + special-cases it. - **Different `sub`** — not a re-authorization at all but decision 2's account change. It takes the - explicit confirm, and a decline or a short scope set runs "discard the new grant" below, which - may revoke, because that token belongs to a different account's grant. - -We never revoke on a same-`sub` re-authorization. That is the guarantee, and it is narrower than -the one an earlier draft of this memo made — "a re-authorization can never leave an account holding -less than it held before" — which was false: honoring it by revoking the short token would have -taken the stored refresh token with it and stripped the read surface the teacher already had, on -every device she uses. + explicit confirm, and a decline or a short scope set runs "discard the new grant" below. That + operation does not revoke either, and for the same reason one level out: the account whose token + we are holding may hold a live Buzz grant on a device this installation knows nothing about, and + revoking at project granularity would end it there. + +**We never revoke a grant except on an action the user took against that account** — Disconnect, or +the confirmed half of an account change. That is the guarantee. It is wider than the same-`sub` +rule an earlier draft settled for, because the same-`sub` test asks the wrong question: it looks at +whether *this installation* holds a record, when what decides the blast radius is whether *that +Google account* holds a grant anywhere. It is also narrower than the guarantee the first draft +made — "a re-authorization can never leave an account holding less than it held before" — which was +false: honoring it by revoking the short token would have taken the stored refresh token with it +and stripped the read surface the teacher already had, on every device she uses. T12 names a test with two token responses for one grant: a stored binding for `sub` S holding the full union, a re-authorization for S returning only `calendar.events`, and the assertions that no @@ -160,9 +194,53 @@ Without step 3 the credential for pubkey A's Google account would be written und surface would render A's calendar, and decision 2's "connecting a different Google account requires an explicit confirm" would never fire, because from B's side that write is a first connect. +**Connect is single-flight per identity.** At most one transaction record exists for an identity at +a time. Pressing Connect again — a double press, or a second channel's Connect button — supersedes +the live record rather than adding one: the old record is dropped, its listener is closed and its +port released, and only then is the new record created and the new browser tab opened. A callback +for a superseded transaction therefore arrives carrying a `state` the app no longer knows, and +step 1 drops it. Newest wins by construction, enforced by the check that is already there rather +than by a second mechanism. Without this, two flows for one identity each hold a valid distinct +`state` and each pass the pubkey check, so the *older* one, completing second, would overwrite the +account the user just chose — or, if the two flows chose different accounts, drive decision 2's +account-change path against the binding the user made seconds earlier. That is `AGENTS.md` +Review-Proven Rule 2 applied to connect, which this memo already applies to refreshes (decision 5) +and to late list responses (decision 4). + T12 names a test for each: a wrong `state`, a missing `state`, a second concurrent callback -replaying a valid `state`, a callback after the deadline, and an identity switch between the -browser opening and the callback. +replaying a valid `state`, a callback after the deadline, an identity switch between the browser +opening and the callback, and two Connect flows for one identity completing in reverse order — +where only the newest persists and the older's callback is dropped without a token exchange. + +**Every Google request is bounded, and so is every page loop.** The five-minute deadline above +bounds waiting for the *browser*. It bounds no HTTP call, and a token endpoint that accepts the +connection and then never finishes its response would otherwise leave Connect in no state at all — +past its listener deadline, short of stage 2, with nothing to report. One shared client carries +every Google request this contract makes — token exchange, refresh, revocation, `events.list`, +`calendarList` — and its limits are stated once here instead of per call site: + +- **Deadlines.** Ten seconds to establish the connection, thirty seconds without a received byte, + and sixty seconds total for one request including redirects and body read; whichever expires + first aborts it. An operation built from several requests — a connect, a window refresh — carries + its own total of ninety seconds. Decision 5's sign-out budget is tighter and wins where it + applies. +- **A response byte cap** of eight mebibytes. A body still arriving at the cap aborts the request + rather than buffering on; no legitimate response on this surface comes near it. +- **Page bounds.** A paged read stops at twenty pages, or at the item cap for that resource — 2,500 + events for one window, 500 `calendarList` entries — whichever comes first, and treats a + `nextPageToken` it has already seen in the same operation as a protocol error rather than a page. + Reaching a bound is not silent: it is logged with the resource and the page count, and the window + renders from what arrived, marked stale. +- **Cancellation.** Every request is cancellable, and is cancelled on identity change, on + `resetCommunityState()` (decision 4), on Disconnect, and at shutdown. A cancelled request writes + nothing and delivers nothing — the same fence decision 4 states for a late response. + +An aborted, capped or page-bounded request is a **transient** failure everywhere in decision 6's +table. It is never access loss and never terminal auth: a stall proves nothing about the ACL or the +grant, and Rule 4 asks for a bound, not for a verdict. T12 binds this to a test server that accepts +a connection and then sends nothing, one that sends a body past the cap, and one that answers every +page with the same `nextPageToken`. Each must produce a transient failure inside the stated +deadline, with no purge, no state change and no unbounded loop. **When connect fails.** Connect-time failure is its own axis, not one of decision 6's post-connect states. Connect is a pipeline, and every stage has a failure; the list runs to the success @@ -186,9 +264,8 @@ Google grant exists and we hold nothing: "Couldn't finish connecting" state that lists those three causes in that order, names the Workspace domain this client accepts, and offers Try again. 2. **Exchanged / exchange failed.** A `state`-matched callback whose code exchange fails (network, - 5xx, an expired or replayed code). No token was issued, so there is nothing to revoke, but the - user's consent already created a grant record on their account. The state is "Couldn't finish - connecting", with Try again, and one sentence saying Buzz may appear in their Google account's + 5xx, an expired or replayed code). No token was issued, but the user's consent already created a + grant record on their account. The state is "Couldn't finish connecting", with Try again, and one sentence saying Buzz may appear in their Google account's third-party access list until they retry or remove it there. Retry is a fresh transaction, never a reuse of the spent one. 3. **Scope-verified / insufficient.** The readback rule above. An insufficient grant persists @@ -197,8 +274,9 @@ Google grant exists and we hold nothing: account and we hold its refresh token. `SecretStore::store` returns `Result<(), String>` (`desktop/src-tauri/src/secret_store.rs:729`), and a locked or unavailable keychain is a real return value even after the preflight passed. On `Err` the app does not fall back to plaintext - and does not report a bare failure: it runs "discard the new grant" and reports "Couldn't finish - connecting" together with whatever that operation resolved to. + and does not report a bare failure: it runs "discard the new grant" and reports "Couldn't + finish connecting" together with that operation's disclosure sentence, which is the same + sentence stage 2 shows. 5. **Connected.** The binding is written and, in the *same* blob mutation, any revocation job this installation still has pending for the same (project, `sub`) is discarded (decision 5). @@ -207,41 +285,64 @@ abort above and a declined account-change confirm (decision 2) all end holding a just issued and no right to keep it. They run the same ordered steps, stated here rather than re-derived at four call sites that can drift apart: -1. **The same-`sub` exception.** If the new token's `sub` matches a binding this installation - already holds for this Cloud project, nothing is revoked: the token is dropped in memory and the - operation is done. Revoking it would revoke that binding too — one grant per (project, account). -2. Otherwise post the refresh token to Google's revocation endpoint. HTTP 200, or a terminal - `invalid_token` meaning the grant is already gone, finishes the operation. -3. On any other outcome, write a decision 5 revocation job for that (project, `sub`). Its retry - schedule finishes what the network could not, and the user is told a revocation is pending. -4. If the journal write also fails, the failure message says in words that a Google grant for Buzz - may still exist, and links to the user's Google account permissions page. - -A grant with no local record and no durable instruction to remove it is exactly Review-Proven Rule -1's catch-with-no-durable-record, so it is the one outcome this list refuses to leave silent — and -it is refused in one place instead of once per stage. - -T12 names a test per path into that operation: an exchange failure (no credential, no partial -record, no revocation job); an insufficient scope set with the revocation endpoint reachable (the -token is revoked, nothing persists); an insufficient scope set with the network down (a revocation -job survives and the next launch retries it); a persist failure after a successful exchange (the -issued token is revoked and the failure is reported); a persist failure whose revocation also fails -(a job survives and the next launch retries it); an identity switch between the exchange and the -persist (the token is revoked and nothing is written under either identity); and a declined -account-change confirm (the token is revoked and the previous binding is untouched). +1. **The token is dropped in memory.** Nothing is persisted: no binding record, no partial record, + no revocation job, no cache row. +2. **Nothing is posted to the revocation endpoint** — not for a `sub` this installation already + holds, and not for one it has never seen. +3. **The grant is disclosed in words**, inside the same failure message: Buzz may appear in that + Google account's third-party access list until the user retries or removes it there, with a link + to the Google account permissions page. That is stage 2's sentence, reused rather than + paraphrased per stage. + +**Why this operation does not revoke.** Revocation is project-granular (decision 5): posting the +token we have just received removes every scope that Google account granted this Cloud project and +invalidates every token issued under it, on every device that account uses. The only local thing +about that token is that we are holding it. Device A already runs a working Buzz connection for +account B; on device C, bound to account A, the user picks B and then declines the account-change +confirm — and a revoking discard would disconnect device A with a dialog the user just refused. The +identical harm reaches device A through the insufficient-scope stage, the persist-failure stage and +the identity-mismatch abort, none of which is a statement by the user about that account's other +devices. This is the harm decision 1 already refuses one section up for the same-`sub` case; the +only change is that the guard no longer asks whether *this installation* holds a record, which was +never the question that decided the blast radius. + +What we accept instead is a grant with no local token. It is not silent, which is what `AGENTS.md` +Review-Proven Rule 1 asks of a caught failure: the failure message names the live grant, names +where to remove it, and the retry that replaces it is one button away. The earlier draft's +alternative — a durable revocation job for a grant the user never asked to end — satisfied the +letter of Rule 1 by scheduling the damage instead of reporting it. + +The **confirmed** half of an account change is the one place a token issued by this flow still +leads to a revocation, and it is the previous binding's token that goes, not the new one +(decision 2). Disconnect and sign-out (decision 5) are the others. No other path in this contract +reaches the revocation endpoint. + +T12 names a test per path into that operation: an exchange failure, an insufficient scope set, a +persist failure after a successful exchange, an identity switch between the exchange and the +persist, and a declined account-change confirm. Each asserts the same three things — **no request +reaches the revocation endpoint**, no journal entry is written, and no credential is persisted. The +decisive case is the two-installation test this rule exists for: installation 1 holds a live +binding for account B; on installation 2, bound to account A, the user completes consent for B and +declines the confirm; installation 1's binding must still work afterwards, and its next refresh must +not return `invalid_grant`. None of these states is "not shared with your account" (decisions 4 and 6): that message means a connected account the calendar's ACL does not list, and showing it to someone who never reached the consent screen sends them to an admin to fix an ACL that is not the problem. -**Reason.** `calendarlist.readonly` is the only way to resolve the business calendar's id and the -caller's `accessRole` without asking for calendar management. Asking for the whole union at connect -and deriving the surface from the readback is what keeps "read-only member" a real state without a -second authorization: Google will not give an installed app an incremental upgrade, so a design -that depends on one would hand a teacher who drags a class a token carrying only `calendar.events`, -and the same readback rule that protects the read-only surface would then correctly record that -`calendarlist.readonly` and `events.readonly` are gone — trying to edit would break reading. +**Reason.** The events scopes are the smallest pair that answers every question this surface asks: +one `events.list` on the mapped calendar returns the window, the calendar's `summary` and time +zone, and the caller's `accessRole`, without asking for calendar management and without depending +on a `CalendarList` entry Google no longer creates when a calendar is shared (decision 3). +`calendarlist.readonly` is asked for because a picker is the obvious next entry point and a second +consent to add it later is exactly what this decision refuses; it is kept out of the read set +because nothing in v1 breaks without it. Asking for the whole union at connect and deriving the +surface from the readback is what keeps "read-only member" a real state without a second +authorization: Google will not give an installed app an incremental upgrade, so a design that +depends on one would hand a teacher who drags a class a token carrying only `calendar.events`, and +the same readback rule that protects the read-only surface would then correctly record that +`events.readonly` is gone — trying to edit would break reading. Verifying the full union before replacing a credential is the same rule applied to the re-authorization path. Naming the publishing status is not paperwork: it is the single setting that decides whether decision 8's terminal branch is rare or weekly. Requiring a Buzz-only Cloud project @@ -258,7 +359,8 @@ merely closed the browser window to a Workspace admin. **Decision.** One Google account per Buzz identity per installation. The Buzz side of the binding is the active identity's pubkey hex (`get_identity`, `desktop/src-tauri/src/commands/identity.rs`); the Google side is the OIDC `sub`, not the email. The record — refresh token, access token, -expiry, granted scopes, `sub`, email for display — is stored in the OS keychain through +expiry, granted scopes, `sub`, email for display, and the `binding_generation` defined below — is +stored in the OS keychain through `SecretStore` under a key namespaced by pubkey hex. `SecretStore` keeps all secrets as one JSON blob (the `BLOB_KEY` username `secrets`, `desktop/src-tauri/src/secret_store.rs:42-44`; the service name is not a constant in that file but comes from `keyring_service()`, @@ -267,18 +369,51 @@ and a `buzz-desktop-dev*` service otherwise), so this costs no extra keychain pr revocation journal is a *second, separate key* in that same blob; it is deliberately not a field of this record, because it has to outlive it. Token exchange, refresh and every Google API call happen in Rust. The webview receives a redacted status struct only: connected, email, granted -scopes, expiry, state. Connecting a different Google account requires an explicit confirm and -revokes the previous grant first (decision 5). The pubkey → Google-account mapping is never -published to the relay. +scopes, expiry, state. Connecting a different Google account takes an explicit confirm, and the +confirm can only be raised *after* the token exchange, because `sub` is the thing that tells us the +account differs. On confirm, the **previous** binding's grant is revoked exactly as decision 5's +Disconnect revokes it, and the confirm carries decision 5's sentence for it: this ends the previous +Google account's Buzz access on every device. On decline, the new token runs decision 1's "discard +the new grant" — no revocation of either account — and the previous binding is untouched. The +pubkey → Google-account mapping is never published to the relay. + +**`binding_generation` — what it is, when it changes, and what it survives.** It is a field of the +binding record, not a process-local counter: 128 bits from the OS CSPRNG, drawn fresh whenever a +binding record is written, never derived from a clock, a sequence or a hash of the credential. + +- **Minted** on first connect, and again on every *credential replacement* — a re-authorization + that stores a new refresh token, and an account change. Every mint is a new CSPRNG draw and never + an increment, so no caller can predict or reconstruct one. +- **Unchanged** by an ordinary access-token refresh, by an app restart, by a community switch and + by every read. A refresh writes a new access token and expiry into the record and leaves the + generation alone, which is what lets a cache row stay valid across an ordinary week. +- **Carried** by everything that outlives the process: decision 5's journal entries copy it, + decision 6's cache rows key on it, and this decision's command handles are minted against it. + Each compares against the value read back from the persisted record, never against one still held + in memory from before a restart. +- **Gone** with the record. A binding deleted by Disconnect takes its generation with it and the + next connect mints a new one, so nothing written before a Disconnect can match anything after it. + +A process-local counter is the defect this field exists to prevent, and it fails in exactly the +place the fence matters: it resets to its initial value on relaunch, so a cache row written under a +replaced credential matches the record that replaced it, and decision 6's tuple stops fencing at +the moment a restart makes it load-bearing. T12 binds this to a **real store reopen** rather than +an in-memory double — replace the credential, close and reopen both the `SecretStore` and the cache +DB, then present a refresh response and a cache row from before the replacement. Both must be +rejected at the production persist and delivery seam, and the generation read back after the reopen +must equal the one written before it and differ from the one before the replacement. **Where `sub` comes from.** The `sub` is read from the `id_token` in the token-endpoint response Buzz receives directly from Google over TLS, and from nowhere else: never from a UserInfo call, never from a value that passed through the webview, never from the loopback callback's query string. Because that channel is direct and intermediary-free, Google's own OpenID Connect guidance -lets an app use the claims of a token received that way without full signature validation. Buzz -still checks that the `aud` claim equals this client id, and that check is not ceremony: it is what -stops a token minted for some other client from driving the account-change comparison and being -persisted as the binding identity. If a later ticket ever sources `sub` from anywhere but that +lets an app use the claims of a token received that way without full signature validation. That +guidance leans partly on the client secret authenticating the app to Google, which an installed app +does not have (decision 1 calls its secret an identifier, not a credential); here it is PKCE that +binds this response to this app, and the `aud` check below that binds the token to this client. +Buzz still checks that the `aud` claim equals this client id, and that check is not ceremony: it +is what stops a token minted for some other client from driving the account-change comparison and +being persisted as the binding identity. If a later ticket ever sources `sub` from anywhere but that direct response, full ID-token validation — signature against the published JWKS, `iss`, `aud`, `exp` — becomes required at that point, and the ticket that moves it owns that work. @@ -287,20 +422,51 @@ leaving the process; on its own it does not stop the token's *authority* from le renderer can still invoke the commands. So the calendar commands are constrained here: - Every command takes an **opaque binding handle** minted in Rust — a random id valid only for the - current active identity, the current community and the current binding generation. No command - takes a caller-supplied calendar id, and no command enumerates calendars. + current active identity, the current community and the current binding generation. With the one + exception named below, no command takes a caller-supplied calendar id and no command enumerates + calendars. - An event is addressed by an event handle drawn from the rows Rust itself delivered for the current window, never by a raw Google event id supplied by the caller. - On every call Rust re-derives from its own state, not from arguments: the active identity pubkey, - the current community, the channel-to-calendar mapping (decision 4), and the `accessRole` from - the last `calendarList` answer (decision 3). Any mismatch rejects the call. -- Adding a calendar to the mapping goes through a native confirmation outside the webview, showing - the calendar summary and the Google account, so a renderer cannot widen the surface silently. + the current community, the channel-to-calendar mapping (decision 4), and the `accessRole` carried + by the last `events.list` answer for that calendar (decision 3). Any mismatch rejects the call. + +**The one exception, because decision 4 needs one.** The admin conveys the calendar id out of band +and each user sets the mapping locally, so *some* entry point has to accept an id the app has never +seen; the alternatives are a picker over the account's calendars, which the bullets above forbid, +or a decision 4 nobody can carry out. Leaving that unnamed is what would make an implementer either +ship the mapping unusable or quietly widen a list command, so it is named here and bounded: + +- **`propose_calendar_mapping(channel_handle, raw_calendar_id)`** is the only command that accepts + a raw calendar id. It returns **no calendar data of any kind** — not a summary, not a role, not + an existence bit — and it mints no handle. +- Rust alone resolves the id: one `events.list` against it under decision 1's client limits, whose + response carries the summary, the time zone and the `accessRole` that the confirmation and + decision 3 need. +- Any of that is shown **only** in the OS-native confirmation outside the webview, which names the + calendar summary and the Google account. The mapping is written, and a binding handle first + minted for it, only after the user confirms there. +- The value returned to the renderer is one of exactly two: `confirmed` or `not_confirmed`. A + calendar id that does not exist, one this account cannot read, a request that hit a client limit + and a user who pressed Cancel are **indistinguishable** from the renderer's side, so a + compromised renderer cannot turn the command into an existence oracle over the account's + calendars. +- Proposals are rate-limited per identity, and the limit is a Rule 4 bound rather than a warning: a + renderer that spends it receives `not_confirmed` and a logged line, never a faster answer. +- Nothing else moves. Raw calendar ids stay rejected by every list, read and edit command. + +A Rust-owned picker over `calendarList` may be added later as a second entry point; it would end in +the same native confirmation and mint the same handles, and Rust, not the renderer, would +enumerate. It is not in v1 because decision 4's out-of-band convention does not need it, and +because `calendarList` is picker metadata and nothing else (decisions 3 and 6). T12 names handler-level tests: a handle whose calendar is no longer in the current mapping is -rejected, and so is a request that carries a raw calendar id at all; a handle minted under a -previous binding generation is rejected; a handle minted under another identity is rejected; and a -handle minted in community A is rejected after a switch to B. +rejected, and so is a request that carries a raw calendar id to any command but the proposal one; a +handle minted under a previous binding generation is rejected; a handle minted under another +identity is rejected; a handle minted in community A is rejected after a switch to B; a proposal +for a calendar id that does not exist and a proposal for one this account cannot read return the +identical `not_confirmed` value and nothing else; and a proposal whose native confirmation is +declined, or never resolves, writes no mapping and mints no handle. **Reason.** `sub` is stable; a Workspace email can be renamed or reassigned to a different human, and a binding keyed on email would silently follow the address to the new person. Keeping tokens @@ -324,21 +490,37 @@ failure — never as a local "saved" state. Google's ACL also accepts addresses Workspace, and we do not restrict that: such a grant is real read access in Google, it simply produces no Buzz surface, because the OAuth client is Internal (decision 1). -Edit affordances derive from the `accessRole` that `calendarList` returns for that user, and the -table is closed: +Edit affordances derive from the `accessRole` carried by the **`events.list` response for the +mapped calendar** — the same call that fetches the window — and the table is closed: | `accessRole` | Edit affordances | |---|---| | `owner`, `writer` | enabled for every event on the calendar | | `writerWithoutPrivateAccess` | enabled for the events the API returns in full; disabled for events returned as free/busy only, which render as busy blocks with no edit affordance | -| `reader`, `freeBusyReader` | disabled | +| `reader`, `freeBusyReader`, `none` | disabled | | **any other value, present or future** | **disabled — treated as read-only** | The last row is the rule, not a placeholder. Google adds roles; an unrecognized role must never default to allow, because the resulting edit fails at Google with a raw error, which this decision forbids, and it must not be an implementation choice, because "hide edit" and "default allow" are -both defensible in isolation and only one of them is safe. T12 tests one case per row, including -an invented unknown role. +both defensible in isolation and only one of them is safe. + +**Why the role comes from `events.list` and not from `calendarList`.** Google documents that +sharing a calendar with a user no longer inserts it into that user's `CalendarList`. A correctly +shared teacher can therefore be absent from her own `calendarList` while her reads of the calendar +are fully authorized — and a role derived from `calendarList` would be *missing* for exactly that +user: no role, so no affordances, and, under an earlier draft of decision 6's matrix, a purge and +an ACL support call for an ACL that is correct. The `events.list` response carries `accessRole` +with all six values Google defines for it — `none`, `freeBusyReader`, `reader`, +`writerWithoutPrivateAccess`, `writer`, `owner` — beside the `summary` and `timeZone` the surface +needs, and it is authorized by the scopes decision 1 actually requests. One call answers "may I +read this calendar", "what may I do here" and "what is in the window", which is why decision 6 also +makes it the authoritative probe. `calendarList` keeps one job — offering calendars in a picker — +and decides nothing. + +T12 tests one case per row, including an invented unknown role, and one case the rows alone would +not force: a `writer` teacher whose calendar is absent from her `calendarList`, who must still get +her events and her edit affordances. **Reason.** Two access-control systems that can disagree is the failure mode the feature audit named (`2026-09-04-zs-feature-audit.md:57`). Buzz's only gate is channel membership @@ -556,10 +738,13 @@ produced it: |---|---|---| | 403 `userRateLimitExceeded`, `rateLimitExceeded`, `quotaExceeded` | transient (decision 8) | keep the cache, back off; identical handling to 429 | | 403 `forbiddenForNonOrganizer` | write-authorization | fails that write only; the read path and the cache are untouched | -| 403 `insufficientPermissions` on a write | write-authorization | edit affordances drop to read-only; if the scope readback shows `calendar.events` absent, decision 1's re-authorization is offered | +| 403 `insufficientPermissions` on a **write** | write-authorization | edit affordances drop to read-only; if the scope readback shows `calendar.events` absent, decision 1's re-authorization is offered | +| 403 `insufficientPermissions` on a **read** | terminal auth (decision 8) | a scope the read surface depends on is no longer granted: `needs_reconnect`, events dropped as decision 8 states. Never access loss, and never the "no longer shared" message — the ACL is not what changed | | 404 on an **event-level** request (get, patch or delete of one event id) | missing event | drop that one cached row and refresh the window; never access loss, never a message about sharing | -| 403 with an access reason, or 404, on a **calendar-level** read (`calendars.get`, `calendarList.get`, or a list of that calendar's events), still failing after one backed-off retry | access loss | purge that calendar's cached rows; surface "no longer shared with your account" | +| 403 with an access reason, or 404, on the mapped calendar's **`events.list`**, still failing after one backed-off retry | access loss | purge that calendar's cached rows; surface "no longer shared with your account" | +| 404, or plain absence, from `calendarList.get` / `calendarList.list` | picker metadata only | the calendar is not offered in a picker. No purge, no message, no state change — see "Absence from `calendarList`" below | | 401 on a resource call | expiry until proven otherwise | one generation-fenced forced refresh and one replay of that call; the refresh response classifies, never the 401 | +| an aborted, capped or page-bounded request (decision 1's client limits) | transient (decision 8) | keep the cache, back off; a stall proves nothing about the ACL or the grant | | refresh returning `invalid_grant`, or a 401 on the replay after a refresh that succeeded | terminal auth (decision 8) | `needs_reconnect` | | refresh returning `invalid_client` | app error (decision 8) | `app_error`; no reconnect affordance, because a reconnect cannot repair it | | network failure, 5xx, 429 | transient (decision 8) | keep the cache, back off | @@ -570,9 +755,28 @@ someone deleted in Google answers 404 to a read of its cached id, and answers 40 backed-off retry, because a deleted event stays deleted. Classified on status alone that satisfies the access-loss condition, purges the whole calendar, and sends a teacher to an administrator to hunt an ACL that is correct — the support call decision 6 already removed from the rate-limit row, -one row further down. So only a **calendar-level** request can establish access loss; an +one row further down. So only the mapped calendar's **`events.list`** can establish access loss; an event-level 404 removes that event and nothing else. +**One probe, and it is the one the scopes authorize.** `calendars.get` appears nowhere in this +contract. Google documents it as requiring one of `calendar.readonly`, `calendar`, +`calendar.app.created`, `calendar.calendars` or `calendar.calendars.readonly`, and decision 1 +requests none of them — so every call would answer 403 `insufficientPermissions`, for every user, +forever, and land in whichever row caught it. An earlier draft named it as a calendar-level probe; +the row is deleted rather than repaired, because adding `calendar.calendars.readonly` would widen +the grant for a question `events.list` already answers. `calendarList.get` is out of the access-loss +condition for the reason decision 3 gives: absence from a `CalendarList` is an ordinary state for a +correctly shared calendar, so a 404 there is evidence about a picker and about nothing else. What +remains is one probe — `events.list` on the mapped calendar — which is authorized by +`calendar.events.readonly` and by `calendar.events`, both in decision 1's request, and which +returns the `accessRole` decision 3 reads and the window the view draws in the same response. + +A read `insufficientPermissions` is therefore never an unclassified reason. It is the granted set +changing under a live binding — a scope withdrawn at the user's Google account page, or a +re-authorization that stored less than the surface depends on — which is a terminal auth condition +in decision 8's sense and not an ACL event. It drops the events the way decision 8 drops them and +it never says "no longer shared with your account", because sharing is not what moved. + **A 401 is an expiry until a refresh says otherwise.** Access tokens last about an hour and decision 7's poll runs every five minutes, so a token expiring mid-call is ordinary operation, not an authorization event. A 401 on a resource call therefore triggers exactly one forced refresh — @@ -588,19 +792,27 @@ cannot classify. Erring toward transient is deliberate and asymmetric: a wrong t one stale poll interval, while a wrong access-loss call purges a cache and sends a teacher to an administrator to hunt an ACL that was never wrong. -T12 names a test per row, and three more that the rows alone would not force: a deleted event that -answers 404 twice (only that row disappears, no purge and no sharing message), an unknown 403 -reason that repeats until the staleness ceiling (`unreachable`, never "no longer shared"), and the +T12 names a test per row, and five more that the rows alone would not force: a deleted event that +answers 404 twice (only that row disappears, no purge and no sharing message); an unknown 403 +reason that repeats until the staleness ceiling (`unreachable`, never "no longer shared"); the expiry-mid-call race — one 401, one refresh, one successful replay, no state change and no -Reconnect prompt — plus an assertion that five concurrent 401s produce exactly one refresh. - -**Absence from `calendarList` is not evidence of anything.** The list call hides calendars for two -ordinary reasons: `showHidden` defaults to false, and `maxResults` defaults to 100 entries with -`nextPageToken` paging. T12's list call therefore sets `showHidden=true` and pages to exhaustion -before drawing any conclusion — and even then, absence only means "do not offer this calendar in -the picker". It never purges and never produces the "no longer shared" message; only a direct, -classified failure on the calendar resource does. T12 tests a calendar the user hid in Google's own -UI and a calendar sorted past entry 100: both resolve, and neither purges. +Reconnect prompt — plus an assertion that five concurrent 401s produce exactly one refresh; a +calendar whose ACL is valid and which `calendarList` does not list, where `events.list` succeeds +and nothing purges; and a read answering 403 `insufficientPermissions`, which must reach +`needs_reconnect` without ever rendering the "no longer shared" message. + +**Absence from `calendarList` is not evidence of anything.** The list call hides calendars for +three ordinary reasons: `showHidden` defaults to false, `maxResults` defaults to 100 entries with +`nextPageToken` paging, and — the one that matters most here — Google no longer inserts a shared +calendar into the recipient's `CalendarList` at all, so a calendar a teacher can fully read may +simply never have been listed for her. T12's list call therefore sets `showHidden=true` and pages +to exhaustion, or to decision 1's page and item bounds, rejecting a `nextPageToken` it has already +seen — and even then, absence only means "do not offer this calendar in the picker". It never +purges, never produces the "no longer shared" message, and never withholds the events: the mapping +of decision 4 and the `events.list` probe do not consult it. Only a classified failure on +`events.list` for the mapped calendar can establish access loss. T12 tests a calendar the user hid +in Google's own UI, a calendar sorted past entry 100, and a calendar that was shared correctly and +never inserted into the list at all: all three resolve, and none of them purges. **Reason.** Purging on Buzz membership loss would mean Buzz is enforcing Google's ACL, which decision 3 refuses; the user still has the calendar in Google, and their local copy is theirs. The @@ -718,6 +930,56 @@ scope it, and remove it in the same place they manage everyone else. - Agent calendar access through a separate Google principal — blocked on RFC #3227 landing upstream. +## Deviations + +Named here so T12's PR body carries them forward instead of rediscovering them. A deviation is +something this memo does that the plan's T11 or T12 text does not say, or says differently. + +Carried from earlier rounds, unchanged: + +- **Filename.** The plan writes `docs/plans/2026-09-xx-calendar-authorization.md`; this file uses + the resolved date, `2026-09-04-calendar-authorization.md`. The plan file itself was not edited to + point at the resolved name, because other wave-1 tickets share that file and the edit would + collide. +- **Two decisions beyond the literal checklist.** The flow shape — PKCE loopback, every token and + every Google call in Rust, forced by the desktop CSP allowing `connect-src https:` — and the + 24-hour staleness ceiling. Checklist items 6 and 7 cannot be answered without them. +- **Two corrected citations.** The feature audit cites `VISION.md:35` and `ingest.rs:434`; in this + tree they are `VISION.md:37` and `ingest.rs:437`, and this memo cites the latter. +- **v1 mapping authority.** The checklist asks who chooses which channels show the calendar. + Decision 4 answers "the channel admin decides, out of band; each user sets it locally", because + an enforced answer needs a relay-allow-listed kind this fork cannot add. The gap is stated in + decision 4 and the ticket that closes it is in the Deferred list. + +New in this round. Each widens what T12 must build or must test: + +- **T12's mock Google server grows from one behavior to six.** The plan names "two principals, one + calendar shared to both, and an ACL-loss case". This contract additionally requires the mock to + serve: a connection that is accepted and then stalls, a response body past the byte cap, a cyclic + `nextPageToken`, a calendar whose ACL is valid but which is absent from the user's `CalendarList`, + and a read answering 403 `insufficientPermissions`. +- **`accessRole` comes from `events.list`, not from `calendarList`.** T12's named frontend test + "disable edit when write ACL is absent" binds to the `accessRole` in the `events.list` response. + `calendarList` gates nothing and can be absent for a correctly shared user. +- **One desktop command the plan does not name.** `propose_calendar_mapping` (decision 2) is the + only way a calendar id reaches Rust under decision 4's out-of-band convention. It is outside what + "per-user OAuth through desktop commands" describes in T12, and it carries its own tests — + including the one asserting that a non-existent calendar and an unreadable one are + indistinguishable to the caller. +- **The keychain record gains a persisted field.** `binding_generation` (decision 2) lives inside + the `SecretStore` blob and must survive a store reopen. That is more than "tokens in the keychain + (`secret_store` pattern)" implies in T12, and it is the value T12's cache and journal fences are + checked against, so its test uses a real store reopen rather than an in-memory double. +- **No revocation on a failed or declined connect.** T12's traceability table cannot list a + revocation test for a declined confirm, an insufficient scope set, a persist failure or an + identity switch; the assertion for all four is the opposite one — no request reaches the + revocation endpoint. Revocation appears only under Disconnect, the confirmed half of an account + change, and sign-out. The plan's "revocation propagation within the bounded window" test is + unaffected: it exercises decision 7, which is a poll bound, not a discard path. +- **A shared HTTP client with stated limits.** Decision 1's deadlines, byte cap, page and item + bounds, repeated-page-token rejection and cancellation points are a T12 component the plan does + not name, and three of its tests need a server that misbehaves rather than one that answers. + ## Relates to - Upstream RFC #3227 — app-integration agents with scoped credentials (the shape decision 9 From 292f63006f4090f7858be8c763d1fb622f3fd5d3 Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 07:30:27 -0700 Subject: [PATCH 07/11] docs: drop two stale references to the deleted same-sub revocation rule Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- docs/plans/2026-09-04-calendar-authorization.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index d28d005bf3c..33e189266b7 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -112,7 +112,7 @@ Google's revocation endpoint works on: revoking any token removes every scope th to the *project* and invalidates the tokens of every client registered under it. Two token responses for the same account are two views of one grant, not two grants, so "revoke the token we just issued" and "revoke the credential we already hold" are the same operation. The short-scope -branch of a re-authorization is therefore decided by `sub` before anything is revoked: +branch of a re-authorization is therefore decided by `sub`, and neither branch revokes: - **Same `sub` as the stored binding** — the ordinary case, a user upgrading their own connection. The new token is dropped in memory and **never sent to the revocation endpoint**. Buzz @@ -873,8 +873,9 @@ The reconnect action is never hidden behind the same state it repairs, and no fa user out of Buzz, deletes the channel mapping, or shows a raw OAuth error string. Failures are logged with the reason and never with a token or an authorization code; T12 asserts that with a test. Reconnect reuses the connect flow — including decision 1's full-union request and readback — -and keeps the binding when `sub` matches, without ever revoking, per decision 1's same-`sub` rule; -a different `sub` is an account change and takes the explicit confirm from decision 2. +and keeps the binding when `sub` matches, without ever revoking. A different `sub` is an account +change and takes decision 2's explicit confirm, which is raised only after the exchange; declining +it revokes nothing either. **Reason.** Rule 6 — a guard that hides the only recovery affordance is a functional failure, so the reconnect entry lives in two places, one of which does not depend on the broken surface From 033e75caee554741b12253b04f11216913976a9a Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:01:01 -0700 Subject: [PATCH 08/11] docs: cut the calendar authorization memo to nine decisions Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 1054 ++--------------- 1 file changed, 69 insertions(+), 985 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index 33e189266b7..d8d0e39c91c 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -1,990 +1,74 @@ # Calendar authorization contract (T11) -Date: 2026-09-04. Owner ticket: T11 `docs/calendar-authz` in -[`2026-09-04-zs-implementation-plan.md`](2026-09-04-zs-implementation-plan.md). This memo is the -authorization contract that T12 (`feat/google-calendar`) implements and that T12a's view design -assumes. It decides nine questions; each decision below states what we do and why. - -## Summary - -Buzz shows the business calendar that already lives in Google Workspace. It does not own the -calendar, does not grant access to it, and does not reconcile Google's ACL with Buzz channel -membership. Each signed-in human connects their own Google account through an installed-app -OAuth flow; the desktop holds that grant in the OS keychain and renders exactly what that -account's own API calls return. Buzz membership decides where the surface appears; Google -decides what it contains. Agents get no calendar credential in v1. - -**Driving scenario.** Broken English (the client in the plan's R1) keeps its teaching schedule in a -Google Workspace calendar. A teacher opens the school's Buzz channel and expects this week's -classes, and expects to move one when a class is rescheduled. Someone in the same channel who is -not on that calendar's ACL must see nothing. When the school offboards a teacher in Google -Workspace on a Friday, that teacher's view has to go away without anyone touching Buzz. The -decisions below are scored against that scenario. - -The contract assumes every member who should see the calendar has an account in the Workspace that -owns it. Decision 1 states what that assumption costs and what happens when it is false. - -`VISION.md:9` argues against stitching outside services into the workspace. Keeping the calendar a -view onto someone else's system rather than a Buzz data model is what keeps that argument intact. - -Upstream RFC #3227 is narrower than this memo and only decision 9 follows it: it asks for an -extension point so an installed app can supply a managed agent carrying its own scoped credential -that never enters the agent sandbox. It says nothing about a human authorizing a third-party API -from the desktop, which is what decisions 1–8 are; those stand on their own arguments below. - -## Why the two obvious alternatives are out - -- **A native shared-calendar event kind.** Idiomatic Buzz (`AGENTS.md` "Prefer Nostr events over - new HTTP endpoints"), but the relay rejects unknown kinds: `required_scope_for_kind` in - `crates/buzz-relay/src/handlers/ingest.rs:437` returns `Err` for any kind it does not know, and - we do not operate the hosted relay's allow-list. A native kind is an upstream-first ticket, not - this fork's v1. -- **An embedded Google Calendar iframe.** The desktop CSP is `default-src 'self'` with no - `frame-src` (`desktop/src-tauri/tauri.conf.json:39`), and Google Calendar refuses framing. The - view has to be native, which means the app holds a credential, which is why this memo exists. +Date: 2026-09-04. Owner ticket: T11 `docs/calendar-authz`; implemented by T12 `feat/google-calendar` +and assumed by T12a. Buzz renders a Google Workspace calendar the viewer already has access to: it +owns no calendar and grants no access, so effective access is the intersection of Buzz channel +membership and Google's calendar ACL. Upstream RFC #3227 supplies decision 9 and nothing else. ## Decisions -### 1. OAuth scopes requested - -**Decision.** Installed-app authorization-code flow with PKCE (S256) and a loopback redirect on -`http://127.0.0.1:/oauth/callback`, opened in the system browser. **One authorization -request, not two.** Connect asks for the complete set the surface can ever need: - -- `openid` -- `https://www.googleapis.com/auth/userinfo.email` -- `https://www.googleapis.com/auth/calendar.calendarlist.readonly` -- `https://www.googleapis.com/auth/calendar.events.readonly` -- `https://www.googleapis.com/auth/calendar.events` - -Never requested: `https://www.googleapis.com/auth/calendar` (full calendar management) and -`https://www.googleapis.com/auth/calendar.acl`. - -**The granted set is read back from the token response's `scope` string, and it is the only thing -the surface trusts.** Google's granular consent lets a user grant part of what was asked, so the -read-only surface is produced by the readback, not by a smaller request: - -- the read surface needs **at least one of `calendar.events.readonly` / `calendar.events`**, and - nothing else. That is what `events.list` requires, and `events.list` is the only probe this - contract reads a calendar with (decisions 3 and 6); -- `calendar.calendarlist.readonly` is still requested, and is still verified before a credential - *replacement*, but it is **not** part of the read set: it feeds the deferred picker and the "this - calendar is not in your list" diagnostic, neither of which v1 ships or depends on. A user who - unchecks it at the consent screen connects, and the surface is whole, because decision 4's - mapping arrives through decision 2's proposal command and not through a list; -- edit affordances need `calendar.events` **and** decision 3's `accessRole` test; -- anything short of the read set is a *connect failure*, not a degraded surface: the credential is - not persisted, the new grant is discarded by the single operation "When connect fails" defines, - and the user is told which permission the calendar view requires; -- the scope set written to the binding record is **exactly** the readback string, parsed and stored - verbatim. It is never widened locally to the set that was *requested*, and no code path adds a - scope to a stored binding without a token response that justifies it. - -**Why the union at connect, and what the read-only-first alternative costs.** The alternative was -considered: ask at Connect for only `openid`, `userinfo.email`, `calendar.calendarlist.readonly` -and `calendar.events.readonly`, and route write authority through a later authorization. It is the -smaller default privilege, and its cost is stated here rather than waved off. Because incremental -authorization is unavailable to installed apps (below), the upgrade is a **complete second -consent** for the whole union, so every teacher who schedules pays a full re-authorization the -first time she drags a class, on a client that cannot merge the two grants. It lost on three -counts. The teacher who schedules is the ordinary user of this surface, not the exception. Granular -consent already hands the read-only user the smaller grant without a second request — the read set -does not contain `calendar.events`, so unchecking write at the consent screen still connects. And a -second full consent is the event decision 8 works hardest to keep rare: a user who is asked for -Google permissions twice reads the second prompt as a defect, not as a privilege boundary. What the -union costs is real and is not hidden: `calendar.events` authorizes editing events on every -calendar the account can write, personal calendars this contract never discusses included, which is -exactly why decision 2's command constraints are load-bearing rather than decorative. T12 asserts -the half that holds either way: the persisted scope set equals the readback string exactly, for a -full grant and for a partial one, and no test may observe a stored scope that no token response -returned. - -**No incremental authorization.** Google's OAuth 2.0 for Mobile & Desktop Apps guide states that -incremental authorization is not supported for installed apps or devices, and this decision pins -the client to type Desktop app, so `include_granted_scopes=true` is never sent. When the granted -set has to change later — the user unchecked write at consent and now wants to schedule — Buzz -runs a **complete new authorization for the whole union above**, and replaces the stored credential -only after the new token response's `scope` string is verified to contain every scope the current -surface depends on. - -**A grant is one per (Cloud project, Google account), not one per token.** That is the shape -Google's revocation endpoint works on: revoking any token removes every scope the account granted -to the *project* and invalidates the tokens of every client registered under it. Two token -responses for the same account are two views of one grant, not two grants, so "revoke the token we -just issued" and "revoke the credential we already hold" are the same operation. The short-scope -branch of a re-authorization is therefore decided by `sub`, and neither branch revokes: - -- **Same `sub` as the stored binding** — the ordinary case, a user upgrading their own connection. - The new token is dropped in memory and **never sent to the revocation endpoint**. Buzz - re-validates the existing credential with one `events.list` call on the mapped calendar - (decision 3's authoritative probe), reports the scopes that credential actually holds, and says - the extra permission was not granted. Google's grants are additive, so a consent screen on which - the user granted less does not withdraw what the account already granted; if the stored - credential has stopped working anyway, that is decision 8's terminal path and nothing here - special-cases it. -- **Different `sub`** — not a re-authorization at all but decision 2's account change. It takes the - explicit confirm, and a decline or a short scope set runs "discard the new grant" below. That - operation does not revoke either, and for the same reason one level out: the account whose token - we are holding may hold a live Buzz grant on a device this installation knows nothing about, and - revoking at project granularity would end it there. - -**We never revoke a grant except on an action the user took against that account** — Disconnect, or -the confirmed half of an account change. That is the guarantee. It is wider than the same-`sub` -rule an earlier draft settled for, because the same-`sub` test asks the wrong question: it looks at -whether *this installation* holds a record, when what decides the blast radius is whether *that -Google account* holds a grant anywhere. It is also narrower than the guarantee the first draft -made — "a re-authorization can never leave an account holding less than it held before" — which was -false: honoring it by revoking the short token would have taken the stored refresh token with it -and stripped the read surface the teacher already had, on every device she uses. - -T12 names a test with two token responses for one grant: a stored binding for `sub` S holding the -full union, a re-authorization for S returning only `calendar.events`, and the assertions that no -request reaches the revocation endpoint, that the stored refresh token is byte-identical -afterwards, and that the surface still reports the original scopes. - -**A dedicated Google Cloud project.** The OAuth client lives in a Google Cloud project that -contains nothing but this client, owned by the same Workspace that owns the business calendar -(decision 3). This is a requirement, not a preference: Google's revocation semantics are -per *project*, not per client — revoking removes every scope the user previously granted to the -project and invalidates the tokens of every client registered under it. A shared project holding a -dev client, a staging client or an internal script would make decision 5's Disconnect silently -invalidate that user's tokens for all of them. A Buzz-only project is what makes the blast radius -of Disconnect exactly "Buzz", which is what decision 5's confirm text claims it is. - -**The OAuth client itself.** The client is of type **Desktop app** — the type the loopback-plus-PKCE -flow above requires. Its consent screen is published **Internal**, which limits it to that -Workspace's own accounts and needs no Google verification review. Publishing status is **In -production**, never **Testing**: a client left in Testing expires every refresh token after seven -days, which would turn decision 8's terminal `invalid_grant` from an exception into a weekly -reconnect prompt for every user, and would make decision 7's propagation bound read as working -when the grant is simply dead. Shipping this beyond one Workspace means an **External** client and -Google verification for the `calendar.events` scope — a separate ticket, not a config toggle. The -installed-app client secret ships inside the binary and is **not confidential**; it is an -identifier, not a credential. - -**Who can connect.** Publishing the client Internal is also an eligibility rule: only accounts in -that Workspace can consent to it. This contract therefore assumes every Buzz member who should see -the business calendar has a Workspace account. The assumption is not free — Google's calendar ACL -accepts an address outside the Workspace (decision 3), so a member using a personal Google account -can hold real read access in Google and still be unable to consent here at all. In v1 that person -reads the calendar in Google's own web UI and gets no Buzz surface; giving them one means the -External client and the verification ticket named above. Onboarding someone for the calendar is -therefore "give them a Workspace account", not "add their address to the ACL". - -**The authorization transaction.** Nothing about a callback is trusted on its own. Before the -system browser opens, the app creates one transaction record, held in memory only, containing: an -unpredictable `state` (128 bits from the OS CSPRNG), the PKCE verifier, the initiating identity's -pubkey hex, the redirect URI including the bound port, and a deadline five minutes out. At the -callback, all of it is validated in this order before any token exchange: - -1. **`state` matches.** A missing, wrong or already-spent `state` is dropped: no exchange, no - user-visible change, and the listener keeps waiting until its deadline. This is what stops - another local process from driving an uncorrelated callback into the loopback port. -2. **The transaction is unspent and inside its deadline.** It is marked spent before the exchange, - so a second callback replaying a valid `state` is a replay and is dropped. -3. **The active identity still matches the initiating pubkey** — checked before the exchange and - again at persist time while holding the identity mutation lock. Identity can change under a - live flow: `import_identity` is a live command - (`desktop/src-tauri/src/commands/identity.rs:337`) and the codebase already serializes identity - mutation against itself (`identity.rs:229,355,477`). A mismatch at either point aborts the - flow: nothing is written under the new identity, and a token already issued by the exchange goes - through "discard the new grant" below before the failure is reported. - -Without step 3 the credential for pubkey A's Google account would be written under pubkey B, B's -surface would render A's calendar, and decision 2's "connecting a different Google account requires -an explicit confirm" would never fire, because from B's side that write is a first connect. - -**Connect is single-flight per identity.** At most one transaction record exists for an identity at -a time. Pressing Connect again — a double press, or a second channel's Connect button — supersedes -the live record rather than adding one: the old record is dropped, its listener is closed and its -port released, and only then is the new record created and the new browser tab opened. A callback -for a superseded transaction therefore arrives carrying a `state` the app no longer knows, and -step 1 drops it. Newest wins by construction, enforced by the check that is already there rather -than by a second mechanism. Without this, two flows for one identity each hold a valid distinct -`state` and each pass the pubkey check, so the *older* one, completing second, would overwrite the -account the user just chose — or, if the two flows chose different accounts, drive decision 2's -account-change path against the binding the user made seconds earlier. That is `AGENTS.md` -Review-Proven Rule 2 applied to connect, which this memo already applies to refreshes (decision 5) -and to late list responses (decision 4). - -T12 names a test for each: a wrong `state`, a missing `state`, a second concurrent callback -replaying a valid `state`, a callback after the deadline, an identity switch between the browser -opening and the callback, and two Connect flows for one identity completing in reverse order — -where only the newest persists and the older's callback is dropped without a token exchange. - -**Every Google request is bounded, and so is every page loop.** The five-minute deadline above -bounds waiting for the *browser*. It bounds no HTTP call, and a token endpoint that accepts the -connection and then never finishes its response would otherwise leave Connect in no state at all — -past its listener deadline, short of stage 2, with nothing to report. One shared client carries -every Google request this contract makes — token exchange, refresh, revocation, `events.list`, -`calendarList` — and its limits are stated once here instead of per call site: - -- **Deadlines.** Ten seconds to establish the connection, thirty seconds without a received byte, - and sixty seconds total for one request including redirects and body read; whichever expires - first aborts it. An operation built from several requests — a connect, a window refresh — carries - its own total of ninety seconds. Decision 5's sign-out budget is tighter and wins where it - applies. -- **A response byte cap** of eight mebibytes. A body still arriving at the cap aborts the request - rather than buffering on; no legitimate response on this surface comes near it. -- **Page bounds.** A paged read stops at twenty pages, or at the item cap for that resource — 2,500 - events for one window, 500 `calendarList` entries — whichever comes first, and treats a - `nextPageToken` it has already seen in the same operation as a protocol error rather than a page. - Reaching a bound is not silent: it is logged with the resource and the page count, and the window - renders from what arrived, marked stale. -- **Cancellation.** Every request is cancellable, and is cancelled on identity change, on - `resetCommunityState()` (decision 4), on Disconnect, and at shutdown. A cancelled request writes - nothing and delivers nothing — the same fence decision 4 states for a late response. - -An aborted, capped or page-bounded request is a **transient** failure everywhere in decision 6's -table. It is never access loss and never terminal auth: a stall proves nothing about the ACL or the -grant, and Rule 4 asks for a bound, not for a verdict. T12 binds this to a test server that accepts -a connection and then sends nothing, one that sends a body past the cap, and one that answers every -page with the same `nextPageToken`. Each must produce a transient failure inside the stated -deadline, with no purge, no state change and no unbounded loop. - -**When connect fails.** Connect-time failure is its own axis, not one of decision 6's post-connect -states. Connect is a pipeline, and every stage has a failure; the list runs to the success -terminus and is exhaustive on purpose, because the dangerous states are the late ones where a -Google grant exists and we hold nothing: - -0. **Preflight — before the browser opens.** The loopback listener binds its port, and - `SecretStore` is probed with a write-and-delete of a scratch key. A bind failure or a locked or - unavailable keychain is reported in the app while the user is still looking at it, and no - browser opens. There is no plaintext fallback for a calendar credential, ever (decision 2). -1. **Authorized / not authorized.** Two observables here: - - **Google redirects back with an `error` parameter** — the user pressed Cancel - (`access_denied`). The surface returns to plain disconnected: "Google Calendar is not - connected", Connect still offered, no dialog and no retry loop. The parameter is logged, - never shown (decision 8). - - **No callback arrives** before the five-minute deadline, after which the listener closes. - This covers three causes — the browser window was closed, the account is outside the - Workspace (an Internal client answers that in the browser with its own error page and never - redirects), or something local blocked the loopback (a firewall, a proxy, a browser that - refuses `http://127.0.0.1`). The app cannot tell them apart, so it does not guess: one - "Couldn't finish connecting" state that lists those three causes in that order, names the - Workspace domain this client accepts, and offers Try again. -2. **Exchanged / exchange failed.** A `state`-matched callback whose code exchange fails (network, - 5xx, an expired or replayed code). No token was issued, but the user's consent already created a - grant record on their account. The state is "Couldn't finish connecting", with Try again, and one sentence saying Buzz may appear in their Google account's - third-party access list until they retry or remove it there. Retry is a fresh transaction, never - a reuse of the spent one. -3. **Scope-verified / insufficient.** The readback rule above. An insufficient grant persists - nothing and runs "discard the new grant" below. -4. **Persisted / persist failed.** The exchange succeeded, so a live grant exists on the user's - account and we hold its refresh token. `SecretStore::store` returns `Result<(), String>` - (`desktop/src-tauri/src/secret_store.rs:729`), and a locked or unavailable keychain is a real - return value even after the preflight passed. On `Err` the app does not fall back to plaintext - and does not report a bare failure: it runs "discard the new grant" and reports "Couldn't - finish connecting" together with that operation's disclosure sentence, which is the same - sentence stage 2 shows. -5. **Connected.** The binding is written and, in the *same* blob mutation, any revocation job this - installation still has pending for the same (project, `sub`) is discarded (decision 5). - -**"Discard the new grant" — one operation, named once.** Stage 3, stage 4, the identity-mismatch -abort above and a declined account-change confirm (decision 2) all end holding a token the exchange -just issued and no right to keep it. They run the same ordered steps, stated here rather than -re-derived at four call sites that can drift apart: - -1. **The token is dropped in memory.** Nothing is persisted: no binding record, no partial record, - no revocation job, no cache row. -2. **Nothing is posted to the revocation endpoint** — not for a `sub` this installation already - holds, and not for one it has never seen. -3. **The grant is disclosed in words**, inside the same failure message: Buzz may appear in that - Google account's third-party access list until the user retries or removes it there, with a link - to the Google account permissions page. That is stage 2's sentence, reused rather than - paraphrased per stage. - -**Why this operation does not revoke.** Revocation is project-granular (decision 5): posting the -token we have just received removes every scope that Google account granted this Cloud project and -invalidates every token issued under it, on every device that account uses. The only local thing -about that token is that we are holding it. Device A already runs a working Buzz connection for -account B; on device C, bound to account A, the user picks B and then declines the account-change -confirm — and a revoking discard would disconnect device A with a dialog the user just refused. The -identical harm reaches device A through the insufficient-scope stage, the persist-failure stage and -the identity-mismatch abort, none of which is a statement by the user about that account's other -devices. This is the harm decision 1 already refuses one section up for the same-`sub` case; the -only change is that the guard no longer asks whether *this installation* holds a record, which was -never the question that decided the blast radius. - -What we accept instead is a grant with no local token. It is not silent, which is what `AGENTS.md` -Review-Proven Rule 1 asks of a caught failure: the failure message names the live grant, names -where to remove it, and the retry that replaces it is one button away. The earlier draft's -alternative — a durable revocation job for a grant the user never asked to end — satisfied the -letter of Rule 1 by scheduling the damage instead of reporting it. - -The **confirmed** half of an account change is the one place a token issued by this flow still -leads to a revocation, and it is the previous binding's token that goes, not the new one -(decision 2). Disconnect and sign-out (decision 5) are the others. No other path in this contract -reaches the revocation endpoint. - -T12 names a test per path into that operation: an exchange failure, an insufficient scope set, a -persist failure after a successful exchange, an identity switch between the exchange and the -persist, and a declined account-change confirm. Each asserts the same three things — **no request -reaches the revocation endpoint**, no journal entry is written, and no credential is persisted. The -decisive case is the two-installation test this rule exists for: installation 1 holds a live -binding for account B; on installation 2, bound to account A, the user completes consent for B and -declines the confirm; installation 1's binding must still work afterwards, and its next refresh must -not return `invalid_grant`. - -None of these -states is "not shared with your account" (decisions 4 and 6): that message means a connected -account the calendar's ACL does not list, and showing it to someone who never reached the consent -screen sends them to an admin to fix an ACL that is not the problem. - -**Reason.** The events scopes are the smallest pair that answers every question this surface asks: -one `events.list` on the mapped calendar returns the window, the calendar's `summary` and time -zone, and the caller's `accessRole`, without asking for calendar management and without depending -on a `CalendarList` entry Google no longer creates when a calendar is shared (decision 3). -`calendarlist.readonly` is asked for because a picker is the obvious next entry point and a second -consent to add it later is exactly what this decision refuses; it is kept out of the read set -because nothing in v1 breaks without it. Asking for the whole union at connect and deriving the -surface from the readback is what keeps "read-only member" a real state without a second -authorization: Google will not give an installed app an incremental upgrade, so a design that -depends on one would hand a teacher who drags a class a token carrying only `calendar.events`, and -the same readback rule that protects the read-only surface would then correctly record that -`events.readonly` is gone — trying to edit would break reading. -Verifying the full union before replacing a credential is the same rule applied to the -re-authorization path. Naming the publishing status is not paperwork: it is the single setting that -decides whether decision 8's terminal branch is rare or weekly. Requiring a Buzz-only Cloud project -is not paperwork either: it is the only thing that makes Disconnect's user-facing promise true, -because Google revokes at project granularity. PKCE, not the client secret, is what binds the -authorization code to this app, so nothing here rests on that secret staying hidden — but PKCE -binds the code to the *app*, not to the *identity that asked*, which is what the transaction's -`state` and pubkey check add. Refusing to guess which of the three silent causes occurred is -deliberate — a confidently wrong message ("your account is not in the Workspace") sends a user who -merely closed the browser window to a Workspace admin. - -### 2. Which Google account binds to which Buzz identity, and how the binding is stored - -**Decision.** One Google account per Buzz identity per installation. The Buzz side of the binding -is the active identity's pubkey hex (`get_identity`, `desktop/src-tauri/src/commands/identity.rs`); -the Google side is the OIDC `sub`, not the email. The record — refresh token, access token, -expiry, granted scopes, `sub`, email for display, and the `binding_generation` defined below — is -stored in the OS keychain through -`SecretStore` under a key namespaced by pubkey hex. `SecretStore` keeps all secrets as one JSON -blob (the `BLOB_KEY` username `secrets`, `desktop/src-tauri/src/secret_store.rs:42-44`; the -service name is not a constant in that file but comes from `keyring_service()`, -`desktop/src-tauri/src/app_state_keyring.rs:9-23`, which returns `buzz-desktop` for release builds -and a `buzz-desktop-dev*` service otherwise), so this costs no extra keychain prompt. Decision 5's -revocation journal is a *second, separate key* in that same blob; it is deliberately not a field -of this record, because it has to outlive it. Token exchange, refresh and every Google API call -happen in Rust. The webview receives a redacted status struct only: connected, email, granted -scopes, expiry, state. Connecting a different Google account takes an explicit confirm, and the -confirm can only be raised *after* the token exchange, because `sub` is the thing that tells us the -account differs. On confirm, the **previous** binding's grant is revoked exactly as decision 5's -Disconnect revokes it, and the confirm carries decision 5's sentence for it: this ends the previous -Google account's Buzz access on every device. On decline, the new token runs decision 1's "discard -the new grant" — no revocation of either account — and the previous binding is untouched. The -pubkey → Google-account mapping is never published to the relay. - -**`binding_generation` — what it is, when it changes, and what it survives.** It is a field of the -binding record, not a process-local counter: 128 bits from the OS CSPRNG, drawn fresh whenever a -binding record is written, never derived from a clock, a sequence or a hash of the credential. - -- **Minted** on first connect, and again on every *credential replacement* — a re-authorization - that stores a new refresh token, and an account change. Every mint is a new CSPRNG draw and never - an increment, so no caller can predict or reconstruct one. -- **Unchanged** by an ordinary access-token refresh, by an app restart, by a community switch and - by every read. A refresh writes a new access token and expiry into the record and leaves the - generation alone, which is what lets a cache row stay valid across an ordinary week. -- **Carried** by everything that outlives the process: decision 5's journal entries copy it, - decision 6's cache rows key on it, and this decision's command handles are minted against it. - Each compares against the value read back from the persisted record, never against one still held - in memory from before a restart. -- **Gone** with the record. A binding deleted by Disconnect takes its generation with it and the - next connect mints a new one, so nothing written before a Disconnect can match anything after it. - -A process-local counter is the defect this field exists to prevent, and it fails in exactly the -place the fence matters: it resets to its initial value on relaunch, so a cache row written under a -replaced credential matches the record that replaced it, and decision 6's tuple stops fencing at -the moment a restart makes it load-bearing. T12 binds this to a **real store reopen** rather than -an in-memory double — replace the credential, close and reopen both the `SecretStore` and the cache -DB, then present a refresh response and a cache row from before the replacement. Both must be -rejected at the production persist and delivery seam, and the generation read back after the reopen -must equal the one written before it and differ from the one before the replacement. - -**Where `sub` comes from.** The `sub` is read from the `id_token` in the token-endpoint response -Buzz receives directly from Google over TLS, and from nowhere else: never from a UserInfo call, -never from a value that passed through the webview, never from the loopback callback's query -string. Because that channel is direct and intermediary-free, Google's own OpenID Connect guidance -lets an app use the claims of a token received that way without full signature validation. That -guidance leans partly on the client secret authenticating the app to Google, which an installed app -does not have (decision 1 calls its secret an identifier, not a credential); here it is PKCE that -binds this response to this app, and the `aud` check below that binds the token to this client. -Buzz still checks that the `aud` claim equals this client id, and that check is not ceremony: it -is what stops a token minted for some other client from driving the account-change comparison and -being persisted as the binding identity. If a later ticket ever sources `sub` from anywhere but that -direct response, full ID-token validation — signature against the published JWKS, `iss`, `aud`, -`exp` — becomes required at that point, and the ticket that moves it owns that work. - -**The command surface is part of this boundary.** Holding the token in Rust stops the *token* from -leaving the process; on its own it does not stop the token's *authority* from leaving, because the -renderer can still invoke the commands. So the calendar commands are constrained here: - -- Every command takes an **opaque binding handle** minted in Rust — a random id valid only for the - current active identity, the current community and the current binding generation. With the one - exception named below, no command takes a caller-supplied calendar id and no command enumerates - calendars. -- An event is addressed by an event handle drawn from the rows Rust itself delivered for the - current window, never by a raw Google event id supplied by the caller. -- On every call Rust re-derives from its own state, not from arguments: the active identity pubkey, - the current community, the channel-to-calendar mapping (decision 4), and the `accessRole` carried - by the last `events.list` answer for that calendar (decision 3). Any mismatch rejects the call. - -**The one exception, because decision 4 needs one.** The admin conveys the calendar id out of band -and each user sets the mapping locally, so *some* entry point has to accept an id the app has never -seen; the alternatives are a picker over the account's calendars, which the bullets above forbid, -or a decision 4 nobody can carry out. Leaving that unnamed is what would make an implementer either -ship the mapping unusable or quietly widen a list command, so it is named here and bounded: - -- **`propose_calendar_mapping(channel_handle, raw_calendar_id)`** is the only command that accepts - a raw calendar id. It returns **no calendar data of any kind** — not a summary, not a role, not - an existence bit — and it mints no handle. -- Rust alone resolves the id: one `events.list` against it under decision 1's client limits, whose - response carries the summary, the time zone and the `accessRole` that the confirmation and - decision 3 need. -- Any of that is shown **only** in the OS-native confirmation outside the webview, which names the - calendar summary and the Google account. The mapping is written, and a binding handle first - minted for it, only after the user confirms there. -- The value returned to the renderer is one of exactly two: `confirmed` or `not_confirmed`. A - calendar id that does not exist, one this account cannot read, a request that hit a client limit - and a user who pressed Cancel are **indistinguishable** from the renderer's side, so a - compromised renderer cannot turn the command into an existence oracle over the account's - calendars. -- Proposals are rate-limited per identity, and the limit is a Rule 4 bound rather than a warning: a - renderer that spends it receives `not_confirmed` and a logged line, never a faster answer. -- Nothing else moves. Raw calendar ids stay rejected by every list, read and edit command. - -A Rust-owned picker over `calendarList` may be added later as a second entry point; it would end in -the same native confirmation and mint the same handles, and Rust, not the renderer, would -enumerate. It is not in v1 because decision 4's out-of-band convention does not need it, and -because `calendarList` is picker metadata and nothing else (decisions 3 and 6). - -T12 names handler-level tests: a handle whose calendar is no longer in the current mapping is -rejected, and so is a request that carries a raw calendar id to any command but the proposal one; a -handle minted under a previous binding generation is rejected; a handle minted under another -identity is rejected; a handle minted in community A is rejected after a switch to B; a proposal -for a calendar id that does not exist and a proposal for one this account cannot read return the -identical `not_confirmed` value and nothing else; and a proposal whose native confirmation is -declined, or never resolves, writes no mapping and mints no handle. - -**Reason.** `sub` is stable; a Workspace email can be renamed or reassigned to a different human, -and a binding keyed on email would silently follow the address to the new person. Keeping tokens -out of the webview matters because the CSP's `connect-src` already allows `https:` — a token in -the renderer is one XSS away from any host. That same `https:` allowance is why the command -constraints above are load-bearing rather than decorative: an XSS that cannot read the refresh -token can still call a command, and a command that accepted a calendar or event id would let it -enumerate and edit every calendar the account-wide token reaches — personal calendars this contract -never discusses included — and exfiltrate the results through the same allowance. Not publishing the mapping keeps the workspace -identity graph off the relay, where the operator and every channel member would otherwise see who -holds which Google account. - -### 3. Who owns the business calendar, and how sharing is granted - -**Decision.** The business calendar is a Google Workspace secondary calendar owned by the -Workspace (an admin-held account or a resource account), not by any individual's primary calendar. -All sharing is granted in Google Calendar's own ACL — "Make changes to events" for staff who -schedule, "See all event details" for everyone else. Buzz never creates, grants, changes or -revokes a calendar ACL, and holds no scope that would let it. A write Google rejects surfaces as a -failure — never as a local "saved" state. Google's ACL also accepts addresses outside the -Workspace, and we do not restrict that: such a grant is real read access in Google, it simply -produces no Buzz surface, because the OAuth client is Internal (decision 1). - -Edit affordances derive from the `accessRole` carried by the **`events.list` response for the -mapped calendar** — the same call that fetches the window — and the table is closed: - -| `accessRole` | Edit affordances | -|---|---| -| `owner`, `writer` | enabled for every event on the calendar | -| `writerWithoutPrivateAccess` | enabled for the events the API returns in full; disabled for events returned as free/busy only, which render as busy blocks with no edit affordance | -| `reader`, `freeBusyReader`, `none` | disabled | -| **any other value, present or future** | **disabled — treated as read-only** | - -The last row is the rule, not a placeholder. Google adds roles; an unrecognized role must never -default to allow, because the resulting edit fails at Google with a raw error, which this decision -forbids, and it must not be an implementation choice, because "hide edit" and "default allow" are -both defensible in isolation and only one of them is safe. - -**Why the role comes from `events.list` and not from `calendarList`.** Google documents that -sharing a calendar with a user no longer inserts it into that user's `CalendarList`. A correctly -shared teacher can therefore be absent from her own `calendarList` while her reads of the calendar -are fully authorized — and a role derived from `calendarList` would be *missing* for exactly that -user: no role, so no affordances, and, under an earlier draft of decision 6's matrix, a purge and -an ACL support call for an ACL that is correct. The `events.list` response carries `accessRole` -with all six values Google defines for it — `none`, `freeBusyReader`, `reader`, -`writerWithoutPrivateAccess`, `writer`, `owner` — beside the `summary` and `timeZone` the surface -needs, and it is authorized by the scopes decision 1 actually requests. One call answers "may I -read this calendar", "what may I do here" and "what is in the window", which is why decision 6 also -makes it the authoritative probe. `calendarList` keeps one job — offering calendars in a picker — -and decides nothing. - -T12 tests one case per row, including an invented unknown role, and one case the rows alone would -not force: a `writer` teacher whose calendar is absent from her `calendarList`, who must still get -her events and her edit affordances. - -**Reason.** Two access-control systems that can disagree is the failure mode the feature audit -named (`2026-09-04-zs-feature-audit.md:57`). Buzz's only gate is channel membership -(`VISION.md:37`); Google's is the calendar ACL. Reconciling them means one of them is a stale copy -of the other, and the copy will be wrong the day someone is offboarded. Instead Buzz makes no -access decision at all: it renders each account's own answer. Removing someone from the business -means removing them in Google Workspace, which is the same sentence the audit already wrote. -Owning the calendar with the Workspace rather than a person means an offboarded owner does not -take the calendar with them. Closing the role table is the same discipline one level down: the -surface derives from Google's answer, including the answers Google has not invented yet. - -### 4. Which channels show the calendar, and who chooses - -**Decision.** The calendar surface is opt-in per channel, and **in v1 each user sets the mapping -locally**: it is a per-identity, per-community desktop setting in the app-data dir next to the -archive DB, and nothing carries one person's choice to anyone else's installation. Its key is the -tuple (pubkey hex, canonical community relay id, channel id), never the channel id alone — channel -ids are relay-scoped NIP-29 group ids, so one pubkey active on communities A and B would otherwise -match in B a row written for A and draw the calendar in the wrong community's channel. The channel admin (kind:39001, -with membership at kind:39002 — `crates/buzz-core/src/kind.rs:424-426`) decides which calendar id -the channel *should* use and conveys that choice out of band — a pinned message, the channel topic, -onboarding — so in v1 it is a convention the app does not enforce. Admin-owned mapping becomes -enforceable only with the relay-synced kind, which needs a new allow-listed event kind and is -deferred to an upstream-first ticket (see "Why the two obvious alternatives are out"). What T12 can -bind is therefore the local half: the mapping is per-identity and local, and no mapping grants -access. The mapping is a *display* choice and carries no authority — a *connected* channel member -whose Google account is not on the calendar's ACL sees an empty surface with "not shared with your -account", never someone else's events. That message is about the ACL and nothing else; a member who -could not connect in the first place gets decision 1's connect-time state instead, and a member -whose calendar simply could not be reached gets decision 6's unreachable state. - -**A community switch is a removal path, and the calendar module registers for it.** `AGENTS.md` -"Community Switching" makes this a repository contract: switching remounts the React subtree but -leaves module-level singletons alive, so every community-scoped singleton needs its reset wired -into `resetCommunityState()` (`desktop/src/features/communities/useCommunityInit.ts:59`) in the -same change that introduces it — the comments there record a shipped defect from getting this -wrong. The calendar module registers its reset in that inventory, clearing the in-memory token -cache, the refresh poll timer, the pending-request map and the mapping cache. In-flight work is -fenced the way decision 5 fences a refresh: every list, refresh and edit response re-checks the -full tuple — pubkey, community, binding generation, `sub`, calendar id — before it is persisted and -again before it is delivered to the view, so a request started in community A that resolves after a -switch to B writes nothing and renders nothing (Rule 2). T12 names a test for exactly that delayed -A response landing after an A→B switch, and one for the same channel id existing in two -communities. - -**Reason.** Making the mapping powerless is what lets it be stored loosely. If a wrong or stale -mapping could expose event data, it would need the same durability and audit as the ACL itself; -because every read is made with the viewer's own token, the worst outcome of a bad mapping is an -empty panel in the wrong channel. That trade buys v1 out of a relay change we cannot make, and it -is also what makes the v1 authority gap tolerable: an admin whose choice nobody's client enforces -cannot leak anything by being ignored, because the setting they would be enforcing has no power in -the first place. - -### 5. Disconnect behavior - -**Decision.** "Disconnect" is one user action, one durable write, and a resumable remainder. It -works on two *separate* records inside the same `SecretStore` blob, and the separation is the whole -point: - -- the **binding record** of decision 2 — refresh token, access token, expiry, granted scopes, - `sub`, email — under the pubkey-namespaced key; -- the **revocation journal** — a bounded collection of pending revocation jobs, under its own key - in the same blob, and never touched by the code paths that delete a binding. - -A journal entry is keyed by (Cloud project, Google `sub`, job id) and carries: the refresh token, -the display email, the pubkey-namespaced binding key it came from, the binding generation, the -cache-purge scope (the pubkey, community and calendar ids whose cache rows this binding produced — -decision 6's key tuple), the attempt count, the first-attempt time, the next-attempt time, and a -seven-day deadline. It carries the binding key and the purge scope precisely so a replay can finish -the *local* cleanup an interrupted disconnect started, before it touches the network. Rule 4 bounds -the collection: a second Disconnect of the same account supersedes the entry it finds rather than -adding one, so there is one live entry per Google account and at most sixteen entries; at the cap a -new Disconnect is refused with "resolve the pending Google revocations first", because dropping a -durable record is Rule 1's defect and growing without a bound is Rule 4's. - -The ordered effect: - -1. **One blob mutation** inserts the journal entry and removes the binding record together. - `SecretStore` mutates its blob through a closure that receives the whole map - (`desktop/src-tauri/src/secret_store.rs:395`), so this is one keychain write under one lock, not - a `store` (`secret_store.rs:729`) followed by a `delete` (`secret_store.rs:901`). T12 adds the - one method that exposes that closure to callers. -2. **Local cleanup, driven by the entry**: drop the in-memory token cache, fence any in-flight - refresh by the recorded generation so a late response cannot rewrite a deleted record, and purge - the cached events named by the entry's purge scope (decision 6). Every step is idempotent and is - re-run from the entry at replay. -3. **Call Google's revocation endpoint** with the refresh token read from the entry. -4. **Remove the entry** — a second blob mutation — **only** on HTTP 200, or on a terminal - `invalid_token` response, which means the grant is already gone. - -The channel mapping is left in place throughout, so reconnecting returns to the same view. - -That sequence has exactly three durable boundaries, and the state at each one is stated rather than -claimed in general: - -- **Before the mutation.** Nothing has happened. The credential still works and Disconnect can be - pressed again. -- **After the mutation.** There is no usable local credential, and there is a journal entry naming - the binding key, the generation, the purge scope and the token to revoke. Whatever step 2 had - finished, the replay finishes the rest before it calls the network, so no crash can leave a live - credential or an unpurged event cache behind a Disconnect the user already pressed. The earlier - draft's "write the tombstone, then delete the binding" ordering could leave exactly that, and its - tombstone carried neither the binding key nor a purge scope, so the replay could not have - finished the local half. -- **After the revocation returns 200.** The entry is removed. A crash before that removal replays - the revocation, which answers `invalid_token`, which is also terminal — so the replay converges - instead of looping. - -Retries run at next launch and on network recovery with backoff and a capped attempt count; at the -cap, or at the seven-day deadline, the entry is **kept**, not dropped, and settings shows one line -— "Google Calendar revocation is still pending" — naming the account, with Retry and a link to the -user's Google permissions page. Deleting the record the retry depends on is exactly the PR #6269 -defect. - -T12 names a failpoint test at every durable boundary: a crash before the mutation, a crash -immediately after it, a crash part-way through the cache purge, a crash after the purge and before -the revocation call, a 5xx revocation that resumes across a restart, and a crash between a 200 -response and the entry removal. - -**A pending job never revokes a newer grant on this installation.** Disconnect while offline, -change your mind, press Connect, consent again: the connect pipeline's final stage (decision 1, -stage 5) discards any journal entry for the same (project, `sub`) in the same blob mutation that -writes the new binding. Executing that entry could not do what the user asked for anyway — the old -refresh token and the new one are one grant, so revoking the old one would kill the connection just -made and hand the user `needs_reconnect` with no visible cause. The abandonment is stated, not -silent: the old refresh token is left to expire on Google's own schedule, and settings records that -a pending revocation for that account was superseded by a reconnect. A journal entry for a -*different* `sub` is untouched and still runs. - -Across two installations we cannot do this, and we do not pretend to: laptop A's journal is in -laptop A's keychain, so if desktop B reconnects the same account first, laptop A's replay revokes -the grant B is using. That is the cross-device effect the confirm text below already names, -arriving later than the user expected. B does not show a bare auth error for it: the -`needs_reconnect` message names the two things it can honestly be — a disconnect from another -device, or the user removing Buzz at their Google account page — and does not guess between them. - -T12 names a test for disconnect-offline, then reconnect, then restart: no request reaches the -revocation endpoint, the new binding survives, and the superseded entry is gone. It names the -two-account variant too: a pending job for account A still runs after a reconnect of account B. - -**Sign-out.** Full sign-out is a wipe, not a disconnect, and it needs a stated policy rather than -an accident: the boot reset renames the app-data dir and calls `delete_all_with_legacy()` then -`verify_fully_wiped()` on the keychain (`desktop/src-tauri/src/reset.rs:273,315`), which removes -the whole blob — every journal entry with it, and every token a revocation would need. The wipe is -the one path with no second chance, and one revocation attempt does not cover it: an installation -holds a binding *per identity*, and importing another identity is a live command -(`desktop/src-tauri/src/commands/identity.rs:337`), so two pubkeys each connected to their own -Google account is an ordinary state, not an exotic one. Sign-out therefore **enumerates every -binding and every pending journal entry in the blob, across every identity**, and attempts each -one under **one total deadline** of a few seconds — one budget for the whole set, no retry loop, -and no per-account budget that a slow first account can spend. Whatever is unresolved when that -deadline expires is named: the confirm lists each account by email — "these Google Calendar grants -could not be revoked and stay active until you remove Buzz at your Google account page" — with the -link, and the wipe proceeds anyway, because a sign-out that refuses to sign out is worse than a -disclosed live grant. T12 names a test with two identities, two Google accounts and overlapping -failures across a restart: both disconnects fail offline, both entries survive the restart and both -retry; then a sign-out whose first revocation succeeds and whose second fails names exactly the -second account in the confirm and still wipes. - -**This is not a per-device action, and its blast radius is the Cloud project.** Google's -revocation endpoint removes every scope the user previously granted to the OAuth client's *Cloud -project* and invalidates the tokens of every client registered under that project — not one -machine's copy, and not only the client that called it. Decision 1 requires a Buzz-only project -precisely so that "every client under the project" means Buzz and nothing else; without that -requirement, a teacher pressing Disconnect could invalidate their tokens for a sibling client the -confirm text never mentioned. Within Buzz, an identity's pubkey can be live on more than one -installation (decision 2 binds one Google account per identity *per installation*), so -disconnecting on the laptop ends the desktop's grant too, and the second installation finds out -only as an `invalid_grant` on its next refresh (decision 8). We accept that rather than engineer -around it, but we do not let it arrive unexplained: the confirm reads "this disconnects Google -Calendar for Buzz on all your devices", and the resulting `needs_reconnect` elsewhere names the -cause instead of showing a bare auth error. - -**Reason.** `AGENTS.md` Review-Proven Rule 1 — a caught failure leaves a durable retry record or -propagates; a journal stored *inside* the record the operation deletes is not a journal, which is -why the journal is its own key with its own deletion rule. Rule 5 — one user action is one atomic -persist, taken literally rather than approximated: the two writes that used to be steps 1 and 2 are -one blob mutation, so the torn state between them — a usable credential, an unpurged cache, and a -journal entry that would revoke the grant at the next launch — has no prefix to occur in. Rule 4 — -the journal is a capped collection with a deadline and a stated behavior at the cap, not an -unbounded queue. Rule 2 — the generation fence stops a completing refresh from resurrecting a -deleted binding. -Per-device revocation would need a distinct OAuth client or a distinct Google account per machine; -both are worse than the cross-device effect, and a "disconnect" that quietly leaves a live grant -on a machine the user no longer has is the worst option of the three. - -### 6. Cached event data on disconnect and on membership loss - -**Decision.** Cached events are a bounded render cache in its own SQLite file in the nest — not -mixed into the relay archive tables, whose rows carry a relay access proof that calendar rows do -not have (`desktop/src-tauri/src/archive/mod.rs:1-19`). A row's key is the full tuple (pubkey hex, -canonical community relay id, binding generation, Google `sub`, calendar id, event id), for -decision 4's reason: identity, community and binding generation all change under a running app, and -a key missing any of them lets a response written for one of them be read back under another. That -tuple is re-checked before every persist and again before every delivery to the view. The cache -holds only the expansion window the view needs (window and recurrence expansion are T12a's to size) -under a hard row cap, and every row carries the timestamp of the refresh that produced it. - -- **On disconnect:** purged, as part of decision 5. -- **On loss of Buzz channel membership:** the channel's mapping row is dropped and the surface - disappears from that channel; the event cache is *not* purged. The user's Google access did not - change. -- **On loss of Google access:** only a *verified read failure on the calendar resource itself* - purges, classified by the table below. -- **When we cannot tell** (network failure, refresh failing): the last-good view is shown marked - stale with its refresh time, edits are disabled, and at a 24-hour staleness ceiling the events - are dropped and the binding enters the **`unreachable`** state — Retry plus a line about the - network, *not* decision 8's `needs_reconnect`. - -**Classifying a failure.** A 403 is never classified on its status alone. The decision is a -function of the HTTP status, the structured `reason` in the error body, and the operation that -produced it: - -| Response | Class | Effect | -|---|---|---| -| 403 `userRateLimitExceeded`, `rateLimitExceeded`, `quotaExceeded` | transient (decision 8) | keep the cache, back off; identical handling to 429 | -| 403 `forbiddenForNonOrganizer` | write-authorization | fails that write only; the read path and the cache are untouched | -| 403 `insufficientPermissions` on a **write** | write-authorization | edit affordances drop to read-only; if the scope readback shows `calendar.events` absent, decision 1's re-authorization is offered | -| 403 `insufficientPermissions` on a **read** | terminal auth (decision 8) | a scope the read surface depends on is no longer granted: `needs_reconnect`, events dropped as decision 8 states. Never access loss, and never the "no longer shared" message — the ACL is not what changed | -| 404 on an **event-level** request (get, patch or delete of one event id) | missing event | drop that one cached row and refresh the window; never access loss, never a message about sharing | -| 403 with an access reason, or 404, on the mapped calendar's **`events.list`**, still failing after one backed-off retry | access loss | purge that calendar's cached rows; surface "no longer shared with your account" | -| 404, or plain absence, from `calendarList.get` / `calendarList.list` | picker metadata only | the calendar is not offered in a picker. No purge, no message, no state change — see "Absence from `calendarList`" below | -| 401 on a resource call | expiry until proven otherwise | one generation-fenced forced refresh and one replay of that call; the refresh response classifies, never the 401 | -| an aborted, capped or page-bounded request (decision 1's client limits) | transient (decision 8) | keep the cache, back off; a stall proves nothing about the ACL or the grant | -| refresh returning `invalid_grant`, or a 401 on the replay after a refresh that succeeded | terminal auth (decision 8) | `needs_reconnect` | -| refresh returning `invalid_client` | app error (decision 8) | `app_error`; no reconnect affordance, because a reconnect cannot repair it | -| network failure, 5xx, 429 | transient (decision 8) | keep the cache, back off | - -**The matrix closes on the operation, not only the status.** Google documents 404 for two -different things: a resource that never existed, and a calendar the user cannot access. An event -someone deleted in Google answers 404 to a read of its cached id, and answers 404 again to the -backed-off retry, because a deleted event stays deleted. Classified on status alone that satisfies -the access-loss condition, purges the whole calendar, and sends a teacher to an administrator to -hunt an ACL that is correct — the support call decision 6 already removed from the rate-limit row, -one row further down. So only the mapped calendar's **`events.list`** can establish access loss; an -event-level 404 removes that event and nothing else. - -**One probe, and it is the one the scopes authorize.** `calendars.get` appears nowhere in this -contract. Google documents it as requiring one of `calendar.readonly`, `calendar`, -`calendar.app.created`, `calendar.calendars` or `calendar.calendars.readonly`, and decision 1 -requests none of them — so every call would answer 403 `insufficientPermissions`, for every user, -forever, and land in whichever row caught it. An earlier draft named it as a calendar-level probe; -the row is deleted rather than repaired, because adding `calendar.calendars.readonly` would widen -the grant for a question `events.list` already answers. `calendarList.get` is out of the access-loss -condition for the reason decision 3 gives: absence from a `CalendarList` is an ordinary state for a -correctly shared calendar, so a 404 there is evidence about a picker and about nothing else. What -remains is one probe — `events.list` on the mapped calendar — which is authorized by -`calendar.events.readonly` and by `calendar.events`, both in decision 1's request, and which -returns the `accessRole` decision 3 reads and the window the view draws in the same response. - -A read `insufficientPermissions` is therefore never an unclassified reason. It is the granted set -changing under a live binding — a scope withdrawn at the user's Google account page, or a -re-authorization that stored less than the surface depends on — which is a terminal auth condition -in decision 8's sense and not an ACL event. It drops the events the way decision 8 drops them and -it never says "no longer shared with your account", because sharing is not what moved. - -**A 401 is an expiry until a refresh says otherwise.** Access tokens last about an hour and -decision 7's poll runs every five minutes, so a token expiring mid-call is ordinary operation, not -an authorization event. A 401 on a resource call therefore triggers exactly one forced refresh — -fenced by the binding generation, and single-flighted so a burst of concurrent 401s produces one -refresh and not one each — and then one replay of the call. Only the refresh response can be -terminal, which is Google's own instruction for this status: get a new access token with the -refresh token, and send the user through the OAuth flow only if that fails. A 401 on the replay -after a refresh that succeeded is terminal, because nothing further can repair it. - -An unrecognized `reason` on a read stays transient however often it repeats. It never ages into -access loss; it ages into `unreachable` at the staleness ceiling, like any other condition we -cannot classify. Erring toward transient is deliberate and asymmetric: a wrong transient call costs -one stale poll interval, while a wrong access-loss call purges a cache and sends a teacher to an -administrator to hunt an ACL that was never wrong. - -T12 names a test per row, and five more that the rows alone would not force: a deleted event that -answers 404 twice (only that row disappears, no purge and no sharing message); an unknown 403 -reason that repeats until the staleness ceiling (`unreachable`, never "no longer shared"); the -expiry-mid-call race — one 401, one refresh, one successful replay, no state change and no -Reconnect prompt — plus an assertion that five concurrent 401s produce exactly one refresh; a -calendar whose ACL is valid and which `calendarList` does not list, where `events.list` succeeds -and nothing purges; and a read answering 403 `insufficientPermissions`, which must reach -`needs_reconnect` without ever rendering the "no longer shared" message. - -**Absence from `calendarList` is not evidence of anything.** The list call hides calendars for -three ordinary reasons: `showHidden` defaults to false, `maxResults` defaults to 100 entries with -`nextPageToken` paging, and — the one that matters most here — Google no longer inserts a shared -calendar into the recipient's `CalendarList` at all, so a calendar a teacher can fully read may -simply never have been listed for her. T12's list call therefore sets `showHidden=true` and pages -to exhaustion, or to decision 1's page and item bounds, rejecting a `nextPageToken` it has already -seen — and even then, absence only means "do not offer this calendar in the picker". It never -purges, never produces the "no longer shared" message, and never withholds the events: the mapping -of decision 4 and the `events.list` probe do not consult it. Only a classified failure on -`events.list` for the mapped calendar can establish access loss. T12 tests a calendar the user hid -in Google's own UI, a calendar sorted past entry 100, and a calendar that was shared correctly and -never inserted into the list at all: all three resolve, and none of them purges. - -**Reason.** Purging on Buzz membership loss would mean Buzz is enforcing Google's ACL, which -decision 3 refuses; the user still has the calendar in Google, and their local copy is theirs. The -staleness ceiling is the other half of that: an unbounded offline cache would keep showing a -calendar the user may have lost, with no bound on how long. Dropping the events at the ceiling -while calling the state `unreachable` rather than `needs_reconnect` is the point of decision 8's -split — the grant is not known to be broken, and a teacher back from a weekend with no signal must -not be handed a Reconnect button that opens a browser which cannot reach Google either. Disabling -edits from stale state stops a write built on data we already know may be wrong. Keeping the cache -out of the archive DB keeps its access-proof invariant honest. - -### 7. Revocation propagation timing - -**Decision.** Propagation is poll-bound, and the bound is stated rather than promised as instant. -Access tokens are short-lived (about an hour); the surface refreshes on channel focus and, while -visible, on a bounded poll with backoff — target at most five minutes between refreshes. A -principal removed in Google Workspace loses the view at the first API call after Google applies -the change: worst case one poll interval while the surface is open, or at next open. The 24-hour -staleness ceiling from decision 6 is the outer bound for an app that cannot reach Google at all. -Google push notifications are not used in v1: they need a public HTTPS callback, and the relay's -HTTP surface is deliberately narrow (`AGENTS.md` "Nostr-first HTTP surface"). A revocation — by an -admin, or by the user at their Google account page — invalidates the refresh token, and the next -refresh returns `invalid_grant`, which is decision 8. - -**Reason.** The honest statement of a poll-based system is its interval, not "immediately". Naming -the worst case makes it reviewable; a webhook would shorten it but costs a public endpoint the -fork does not want, so the trade is written down instead of hidden. The backoff and the terminal -state are Rule 4: a persistent failure must not amplify into an unbounded refresh loop. The poll -interval is also why decision 6 puts Google's rate and quota 403s in the transient class: a whole -staff room opening Buzz at five to nine is a load spike, not an ACL change. - -### 8. Refresh-token failure UX - -**Decision.** Four states, handled differently. - -- **Transient** (network, 5xx, 429, and the 403 rate and quota reasons decision 6's table lists, - which Google says to handle identically to 429): exponential backoff with a cap and a terminal - state, no prompt. The view stays visible marked stale (decision 6) until the staleness ceiling. -- **Unreachable** (a transient condition that outlives decision 6's 24-hour ceiling): the events - are dropped, the binding moves to `unreachable`, and the offered action is **Retry**, with one - line about checking the network. It is deliberately not `needs_reconnect`: nothing says the - grant is broken, and a reconnect affordance here opens a browser that cannot reach Google either. -- **Terminal** (`invalid_grant`, a revoked or expired grant, a required scope no longer granted, - or a 401 that survives decision 6's one forced refresh): the refresh loop stops at once, the - binding moves to `needs_reconnect`, event data is dropped, and a quiet, persistent "Reconnect - Google Calendar" action appears on the calendar surface *and* in settings. -- **App error** (`invalid_client`, and any other response saying Buzz's own OAuth client is wrong): - the refresh loop stops, the binding moves to `app_error`, and the state line reads "Buzz's Google - Calendar configuration is not valid". There is **no Reconnect action and no Retry**, because - neither repairs it — the client id, its secret or its Cloud project is wrong, and only a new - build or a console change fixes that. The reason is logged. The data is handled as in - `unreachable`: the last-good view stays visible marked stale with edits disabled, and is dropped - at the 24-hour ceiling. - -`needs_reconnect` is reserved for that terminal class. It is never entered from a network -condition, never from a routine token expiry, and never from an app-configuration failure. -The reconnect action is never hidden behind the same state it repairs, and no failure signs the -user out of Buzz, deletes the channel mapping, or shows a raw OAuth error string. Failures are -logged with the reason and never with a token or an authorization code; T12 asserts that with a -test. Reconnect reuses the connect flow — including decision 1's full-union request and readback — -and keeps the binding when `sub` matches, without ever revoking. A different `sub` is an account -change and takes decision 2's explicit confirm, which is raised only after the exchange; declining -it revokes nothing either. - -**Reason.** Rule 6 — a guard that hides the only recovery affordance is a functional failure, so -the reconnect entry lives in two places, one of which does not depend on the broken surface -rendering. Rule 4 — a terminal auth error must stop the loop, not retry forever against a grant -that will never come back. Splitting transient from terminal is what stops a flaky network from -nagging the user to re-consent; splitting `unreachable` out of terminal is the same argument taken -to the end, because a state name is an instruction to the user and "reconnect" is the wrong -instruction for an outage. Splitting `app_error` out is that argument once more: "reconnect" is a -false instruction for a misconfigured client, because the user can consent all day and the next -refresh fails identically. Keeping a bare 401 out of the terminal class entirely is the same -concern at the other end of the scale — an hourly token lifetime against a five-minute poll makes -the expiry race routine, and a terminal branch that fires on it would turn re-consent into normal -operation. The split also depends on the client's publishing status (decision 1). -Google's seven-day refresh-token expiry binds a client whose user type is External and whose -publishing status is Testing; decision 1's Internal choice already excludes it, so the "In -production, never Testing" rule is belt-and-braces here and load-bearing for the External client -the shipping-beyond-one-Workspace ticket would need. Left in that state, the terminal branch would -stop being an exception and become the normal weekly experience. - -### 9. What an agent may read or write - -**Decision.** Nothing, in v1. No managed agent, ACP harness or MCP server receives a Google -credential, and the desktop registers no calendar command on any agent-facing surface. No calendar -key is ever written into a harness environment; the identity keys already reserved from user -override (`desktop/src-tauri/src/managed_agents/reserved_env_keys.rs`) are the precedent, and a -Google refresh token is the same class of secret with a larger blast radius. T12 binds this with a -falsifiable test: the agent-facing command list and the spawn environment contain no calendar -entry, so deleting the guard fails a test. - -The sanctioned way to give an agent calendar access later is RFC #3227's shape, and it is a -separate ticket: a *distinct Google principal* — a dedicated Workspace account or service account -with its own row in the calendar's ACL — delivered as a scoped credential that is substituted at -egress, never present in the owner-review draft and never inside the agent sandbox. Humans keep -their own grants; the agent gets its own, revocable on its own. - -**Reason.** A per-human OAuth grant handed to an agent lets the agent act as that human inside -Google, outside Buzz's channel gate and outside anything a Workspace admin would expect to see in -an audit log. It also breaks the one-way property this contract rests on: Buzz makes no access -decisions. An agent with its own principal keeps Google authoritative — the admin can see it, -scope it, and remove it in the same place they manage everyone else. - -## Deferred, with the ticket that owns it - -- Event model, expansion window, month and agenda rendering, keyboard and screen-reader - semantics — T12a `docs/calendar-view-design`. -- Implementation of this contract, the mock Google server and the live two-account checklist — - T12 `feat/google-calendar`. -- Relay-synced channel → calendar mapping, and with it admin-owned rather than per-user mapping - (needs a new allow-listed kind) — upstream-first, not scheduled. -- A Buzz calendar surface for members outside the Workspace that owns the client (an External - OAuth client plus Google verification for `calendar.events`, decision 1) — a separate ticket, - not scheduled. -- Agent calendar access through a separate Google principal — blocked on RFC #3227 landing - upstream. - -## Deviations - -Named here so T12's PR body carries them forward instead of rediscovering them. A deviation is -something this memo does that the plan's T11 or T12 text does not say, or says differently. - -Carried from earlier rounds, unchanged: - -- **Filename.** The plan writes `docs/plans/2026-09-xx-calendar-authorization.md`; this file uses - the resolved date, `2026-09-04-calendar-authorization.md`. The plan file itself was not edited to - point at the resolved name, because other wave-1 tickets share that file and the edit would - collide. -- **Two decisions beyond the literal checklist.** The flow shape — PKCE loopback, every token and - every Google call in Rust, forced by the desktop CSP allowing `connect-src https:` — and the - 24-hour staleness ceiling. Checklist items 6 and 7 cannot be answered without them. -- **Two corrected citations.** The feature audit cites `VISION.md:35` and `ingest.rs:434`; in this - tree they are `VISION.md:37` and `ingest.rs:437`, and this memo cites the latter. -- **v1 mapping authority.** The checklist asks who chooses which channels show the calendar. - Decision 4 answers "the channel admin decides, out of band; each user sets it locally", because - an enforced answer needs a relay-allow-listed kind this fork cannot add. The gap is stated in - decision 4 and the ticket that closes it is in the Deferred list. - -New in this round. Each widens what T12 must build or must test: - -- **T12's mock Google server grows from one behavior to six.** The plan names "two principals, one - calendar shared to both, and an ACL-loss case". This contract additionally requires the mock to - serve: a connection that is accepted and then stalls, a response body past the byte cap, a cyclic - `nextPageToken`, a calendar whose ACL is valid but which is absent from the user's `CalendarList`, - and a read answering 403 `insufficientPermissions`. -- **`accessRole` comes from `events.list`, not from `calendarList`.** T12's named frontend test - "disable edit when write ACL is absent" binds to the `accessRole` in the `events.list` response. - `calendarList` gates nothing and can be absent for a correctly shared user. -- **One desktop command the plan does not name.** `propose_calendar_mapping` (decision 2) is the - only way a calendar id reaches Rust under decision 4's out-of-band convention. It is outside what - "per-user OAuth through desktop commands" describes in T12, and it carries its own tests — - including the one asserting that a non-existent calendar and an unreadable one are - indistinguishable to the caller. -- **The keychain record gains a persisted field.** `binding_generation` (decision 2) lives inside - the `SecretStore` blob and must survive a store reopen. That is more than "tokens in the keychain - (`secret_store` pattern)" implies in T12, and it is the value T12's cache and journal fences are - checked against, so its test uses a real store reopen rather than an in-memory double. -- **No revocation on a failed or declined connect.** T12's traceability table cannot list a - revocation test for a declined confirm, an insufficient scope set, a persist failure or an - identity switch; the assertion for all four is the opposite one — no request reaches the - revocation endpoint. Revocation appears only under Disconnect, the confirmed half of an account - change, and sign-out. The plan's "revocation propagation within the bounded window" test is - unaffected: it exercises decision 7, which is a poll bound, not a discard path. -- **A shared HTTP client with stated limits.** Decision 1's deadlines, byte cap, page and item - bounds, repeated-page-token rejection and cancellation points are a T12 component the plan does - not name, and three of its tests need a server that misbehaves rather than one that answers. - -## Relates to - -- Upstream RFC #3227 — app-integration agents with scoped credentials (the shape decision 9 - follows; it does not cover decisions 1–8). -- Upstream PR #1382 — the closed Google Calendar work T12 revives for the OAuth and storage half. -- `2026-09-04-zs-feature-audit.md` §4 — the audit that ruled out a native kind, Cal.com and - iframes. +**1. OAuth scopes requested.** Decision: installed-app PKCE code flow, loopback redirect, Buzz-only +Cloud project (Internal, Production); scopes `openid`, `userinfo.email`, `calendar.events.readonly`, +`calendar.events` and nothing wider — no `calendarList` scope or call. The loopback listener bounds +bytes, sockets and read time. Reason: minimum-scope policy; Rule 4 bounds the listener. + +**2. Google account to Buzz identity.** Decision: one Google account per identity per installation, +identity pubkey hex to the OIDC `sub` of an ID token validated for signature (cached JWKS), `iss`, +`aud` and `exp`. `SecretStore` keeps tokens, scopes, `sub`, email, issuing `client_id` and a CSPRNG +`binding_generation`; no token reaches the webview. Reason: PKCE protects the code, not the JWT. + +**3. Calendar ownership and sharing.** Decision: a Workspace-owned secondary calendar, shared only +in Google's own ACL, which Buzz never reads, writes or reconciles. Affordances come from the +`accessRole` in the mapped calendar's `events.list`, narrowed by event type and organizer; an +unrecognized role is read-only. Reason: a calendar role does not make every event mutable. + +**4. Which channels show it, and who chooses.** Decision: opt-in per channel, keyed by (pubkey, +community relay id, channel id), stored locally per identity; the admin conveys the calendar out of +band. The calendar id is typed into the native confirm, naming account, calendar and channel. +Reason: the mapping carries no authority; the renderer must not pick the destination. + +**5. Disconnect behavior.** Decision: one `SecretStore` blob mutation inserts a revocation journal +entry (refresh token, binding key, generation, purge predicate, deadline) and removes the binding +together; then the purge, then Google's revocation endpoint. Only HTTP 200 clears the entry. Reason: +Rule 5 leaves no torn prefix; `invalid_token` does not prove the grant is gone. + +**6. Cached event data on disconnect and membership loss.** Decision: a bounded SQLite render cache +outside the archive DB, keyed by (pubkey, community, binding generation, `sub`, calendar id, event +id), purged on both. Only a verified `events.list` failure purges for access loss; anything else +stays stale to a 24-hour ceiling. Reason: T12 requires the purge. + +**7. Revocation propagation timing.** Decision: poll-bound — refresh on channel focus and at most +five minutes apart while the surface is visible, with backoff, so a principal removed in Google +keeps the view for one poll interval at worst, or until next open; 24 hours offline is the outer +bound. Reason: a webhook needs a public callback the fork does not want. + +**8. Refresh-token failure UX.** Decision: transient (network, 5xx, 429, quota 403s) backs off +behind a stale view; `unreachable` past the 24-hour ceiling drops events, offering Retry; terminal +(`invalid_grant`, a withdrawn scope, a 401 surviving one forced refresh) offers Reconnect; +`invalid_client` is `app_error` with neither. Reason: Rules 6 and 4; honest state names. + +**9. What an agent may read or write.** Decision: nothing in v1 — no managed agent, ACP harness or +MCP server gets a Google credential and no calendar command is agent-facing, bound by a test whose +removal fails. Later access takes RFC #3227's shape: a distinct Google principal, substituted at +egress. Reason: a human's grant lets an agent act as that human outside Buzz's gate. + +## Risks accepted + +- Revocation is Cloud-project-wide and cross-device: Disconnect on one machine ends the grant on + every machine, and the other learns of it as `needs_reconnect` at its next refresh. +- A sign-out wipe attempts every pending revocation under one deadline and then proceeds; a grant it + could not revoke stays live, and the confirm names that account. +- The v1 mapping is a local per-user setting, so the admin's choice is a convention no client + enforces. Enforcing it needs a relay-allow-listed kind this fork cannot add. +- The Internal OAuth client admits only members of the Workspace that owns it; an outside guest can + hold real read access in Google and still get no Buzz surface. +- Propagation is poll-bound, not immediate, and offline staleness is bounded only by the 24-hour + ceiling. +- Membership loss purges the local cache while the user's Google access is unchanged, so the + calendar returns intact at the next connect or mapping. +- Buzz gains a second authorization system beside the relay, which `VISION.md:37` does not + anticipate. The fork accepts that tension rather than claiming the model is intact. +- T12's "refresh failure surfaces a reconnect state" is narrowed here to the terminal class; T12's + traceability table follows decision 8's four states. +- The filename resolves the plan's `2026-09-xx-calendar-authorization.md`; the plan text is left + alone, because other wave-1 tickets share that file. From e922e36cd6ab63fdf05316d000101d03f2ab67b3 Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:16:19 -0700 Subject: [PATCH 09/11] docs: answer audit findings in the calendar authorization memo Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 130 ++++++++++-------- 1 file changed, 72 insertions(+), 58 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index d8d0e39c91c..cad1c289b65 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -1,74 +1,88 @@ # Calendar authorization contract (T11) -Date: 2026-09-04. Owner ticket: T11 `docs/calendar-authz`; implemented by T12 `feat/google-calendar` -and assumed by T12a. Buzz renders a Google Workspace calendar the viewer already has access to: it -owns no calendar and grants no access, so effective access is the intersection of Buzz channel -membership and Google's calendar ACL. Upstream RFC #3227 supplies decision 9 and nothing else. +Date: 2026-09-04. Owner ticket: T11 `docs/calendar-authz`; implemented by T12 `feat/google-calendar` and assumed by T12a. Buzz renders +a Google Workspace calendar the viewer already has access to: it owns no calendar and grants no access, so effective access is the +intersection of Buzz channel membership and Google's calendar ACL, enforced at each refresh and not at render — a cached paint +outlives membership to decision 6's ceiling. Upstream RFC #3227 supplies decision 9 and nothing else. ## Decisions -**1. OAuth scopes requested.** Decision: installed-app PKCE code flow, loopback redirect, Buzz-only -Cloud project (Internal, Production); scopes `openid`, `userinfo.email`, `calendar.events.readonly`, -`calendar.events` and nothing wider — no `calendarList` scope or call. The loopback listener bounds -bytes, sockets and read time. Reason: minimum-scope policy; Rule 4 bounds the listener. +**1. OAuth scopes and callback correlation.** Decision: installed-app PKCE code flow, loopback redirect, Buzz-only Cloud project +(Internal, Production); scopes `openid`, `userinfo.email` and `calendar.events`, which already grants read and write — no +`calendar.events.readonly`, no `calendarList` scope or call. The exchange reads the token response's granted scopes and writes no +binding on a partial grant. The listener bounds bytes, sockets and read time and carries a CSPRNG `state`: one flow in flight, newest +wins, and a callback whose `state` is missing or unequal is rejected before any exchange. A CSPRNG `nonce` goes out with the request +and must come back in the ID token. Reason: PKCE protects the code in transit, not its origin; PR #1382 already did this +(`6d4f7f796:desktop/src-tauri/src/commands/calendar.rs:181,261`). Tests: `google_calendar_rejects_mismatched_state`, +`google_calendar_requires_nonce_echo`. -**2. Google account to Buzz identity.** Decision: one Google account per identity per installation, -identity pubkey hex to the OIDC `sub` of an ID token validated for signature (cached JWKS), `iss`, -`aud` and `exp`. `SecretStore` keeps tokens, scopes, `sub`, email, issuing `client_id` and a CSPRNG -`binding_generation`; no token reaches the webview. Reason: PKCE protects the code, not the JWT. +**2. Google account to Buzz identity.** Decision: one Google account per identity per installation, identity pubkey hex to the OIDC +`sub` of an ID token validated for signature (cached JWKS), `iss`, `aud`, `exp` and decision 1's `nonce`. Before the binding is +written the flow re-reads the active identity and refuses if the pubkey that began the flow is no longer active — `identity.rs:337` +can replace it mid-flow, and `dms.rs:42` is the in-repo fail-closed precedent for a captured scope re-checked at callback time. +`SecretStore` keeps tokens, scopes, `sub`, email, issuing `client_id` and a CSPRNG `binding_generation`; no token reaches the webview. +Reason: PKCE protects the code, not the JWT. Test: `google_calendar_refuses_binding_after_identity_swap`. -**3. Calendar ownership and sharing.** Decision: a Workspace-owned secondary calendar, shared only -in Google's own ACL, which Buzz never reads, writes or reconciles. Affordances come from the -`accessRole` in the mapped calendar's `events.list`, narrowed by event type and organizer; an -unrecognized role is read-only. Reason: a calendar role does not make every event mutable. +**3. Calendar ownership and sharing.** Decision: a secondary calendar owned by one named durable Workspace role account — such a +calendar has exactly one owner and dies with that account, so offboarding it needs an ownership transfer first — shared only in +Google's own ACL, which Buzz never reads, writes or reconciles. Affordances come from the `accessRole` in the mapped calendar's +`events.list`, narrowed by event type and organizer; an unrecognized role is read-only. Reason: a calendar role does not make every +event mutable. -**4. Which channels show it, and who chooses.** Decision: opt-in per channel, keyed by (pubkey, -community relay id, channel id), stored locally per identity; the admin conveys the calendar out of -band. The calendar id is typed into the native confirm, naming account, calendar and channel. -Reason: the mapping carries no authority; the renderer must not pick the destination. +**4. Which channels show it, and who chooses.** Decision: opt-in per channel, keyed by (pubkey, normalized relay URL, channel id) — +not `Community.id`, a local mutable field separate from the authoritative `relayUrl` (`types.ts:1-4`), so a relay repoint cannot carry +the calendar into a channel on another relay; `channelSnapshot.ts:45-46` is the repo precedent for authorization-sensitive persisted +keys. Stored locally per identity; the admin conveys the calendar out of band. The calendar id is typed into the native confirm, +naming account, calendar and channel. Reason: the mapping carries no authority. -**5. Disconnect behavior.** Decision: one `SecretStore` blob mutation inserts a revocation journal -entry (refresh token, binding key, generation, purge predicate, deadline) and removes the binding -together; then the purge, then Google's revocation endpoint. Only HTTP 200 clears the entry. Reason: -Rule 5 leaves no torn prefix; `invalid_token` does not prove the grant is gone. +**5. Disconnect behavior.** Decision: the binding and its revocation journal entry (refresh token, binding key, generation, purge +predicate, deadline) are one value under one key, so a single `store_all` writes the entry and clears the binding in one mutation — +`mutate_blob` is private (`secret_store.rs:395`), `store_all` (601) only inserts or overwrites, `delete` (901) is a separate mutation. +Then the purge, then Google's revocation endpoint. Only HTTP 200 clears the entry; anything else retries with backoff to a seven-day +ceiling and then stops in a terminal `revocation_unconfirmed` state the settings surface names. Reason: Rule 5 leaves no torn prefix; +`invalid_token` does not prove the grant is gone, and a journal with no terminal state never converges after a crash past a 200. -**6. Cached event data on disconnect and membership loss.** Decision: a bounded SQLite render cache -outside the archive DB, keyed by (pubkey, community, binding generation, `sub`, calendar id, event -id), purged on both. Only a verified `events.list` failure purges for access loss; anything else -stays stale to a 24-hour ceiling. Reason: T12 requires the purge. +**6. Cached event data on disconnect and membership loss.** Decision: a SQLite render cache outside the archive DB, capped at 5,000 +rows per (identity, calendar) and evicted oldest first, keyed by (pubkey, community, binding generation, `sub`, membership epoch, +calendar id, event id); membership loss bumps the epoch. Purged on disconnect, on membership loss, and on decision 8's terminal class, +`invalid_grant` included. Only a verified `events.list` failure — 403 or 404 on the mapped calendar, not a transport error — purges +for access loss; anything else stays stale to a 24-hour ceiling. One rule fences every asynchronous write: a token write, a cache +write and a view delivery each compare their captured (identity pubkey, community, binding generation, `sub`, membership epoch) +against live state at completion and drop on mismatch, so a late refresh cannot resurrect a credential and a late `events.list` cannot +repopulate the cache (`dms.rs:42` again). Reason: T12 requires the purge, and a purge no write fences is undone by a race. -**7. Revocation propagation timing.** Decision: poll-bound — refresh on channel focus and at most -five minutes apart while the surface is visible, with backoff, so a principal removed in Google -keeps the view for one poll interval at worst, or until next open; 24 hours offline is the outer -bound. Reason: a webhook needs a public callback the fork does not want. +**7. Revocation propagation timing.** Decision: poll-bound — refresh on channel focus and at most five minutes apart while the surface +is visible, with backoff. Healthy path: a principal removed in Google keeps the view for one poll interval at worst, or until next +open. Degraded path — a transient failure held behind a stale view per decision 8, or offline — is bounded by the 24-hour ceiling, not +by the poll interval. Reason: a webhook needs a public callback the fork does not want. -**8. Refresh-token failure UX.** Decision: transient (network, 5xx, 429, quota 403s) backs off -behind a stale view; `unreachable` past the 24-hour ceiling drops events, offering Retry; terminal -(`invalid_grant`, a withdrawn scope, a 401 surviving one forced refresh) offers Reconnect; -`invalid_client` is `app_error` with neither. Reason: Rules 6 and 4; honest state names. +**8. Refresh-token failure UX.** Decision: transient (network, 5xx, 429, quota 403s) backs off behind a stale view; `unreachable` past +the 24-hour ceiling drops events, offering Retry; terminal (`invalid_grant`, a withdrawn scope, a 401 surviving one forced refresh) +purges per decision 6 and offers Reconnect; `invalid_client` is `app_error` with neither. Reason: Rules 6 and 4; honest state names. -**9. What an agent may read or write.** Decision: nothing in v1 — no managed agent, ACP harness or -MCP server gets a Google credential and no calendar command is agent-facing, bound by a test whose -removal fails. Later access takes RFC #3227's shape: a distinct Google principal, substituted at -egress. Reason: a human's grant lets an agent act as that human outside Buzz's gate. +**9. What an agent may read or write.** Decision: nothing in v1 — no managed agent, ACP harness or MCP server gets a Google credential +and no calendar command is agent-facing. The denial seam is the credential lookup in the calendar command layer, which refuses a +managed-agent caller; a test that fails when that guard is removed covers agent read and agent write attempts separately, as T12's +traceability table requires. Later access takes RFC #3227's whole shape: a scoped vault reference in place of the user's token, one +owner approval, substitution at egress, and exclusion from both the owner-review draft and the agent sandbox. Reason: a human's grant +lets an agent act as that human outside Buzz's gate. ## Risks accepted -- Revocation is Cloud-project-wide and cross-device: Disconnect on one machine ends the grant on - every machine, and the other learns of it as `needs_reconnect` at its next refresh. -- A sign-out wipe attempts every pending revocation under one deadline and then proceeds; a grant it - could not revoke stays live, and the confirm names that account. -- The v1 mapping is a local per-user setting, so the admin's choice is a convention no client - enforces. Enforcing it needs a relay-allow-listed kind this fork cannot add. -- The Internal OAuth client admits only members of the Workspace that owns it; an outside guest can - hold real read access in Google and still get no Buzz surface. -- Propagation is poll-bound, not immediate, and offline staleness is bounded only by the 24-hour - ceiling. -- Membership loss purges the local cache while the user's Google access is unchanged, so the - calendar returns intact at the next connect or mapping. -- Buzz gains a second authorization system beside the relay, which `VISION.md:37` does not - anticipate. The fork accepts that tension rather than claiming the model is intact. -- T12's "refresh failure surfaces a reconnect state" is narrowed here to the terminal class; T12's - traceability table follows decision 8's four states. -- The filename resolves the plan's `2026-09-xx-calendar-authorization.md`; the plan text is left - alone, because other wave-1 tickets share that file. +- Revocation is Cloud-project-wide and cross-device: Disconnect on one machine ends the grant on every machine, and the other learns + of it as `needs_reconnect` at its next refresh. +- A sign-out wipe attempts every pending revocation under one deadline and then proceeds; a grant it could not revoke stays live, and + the confirm names that account. +- The v1 mapping is a local per-user setting, so the admin's choice is a convention no client enforces. Enforcing it needs a + relay-allow-listed kind this fork cannot add. +- The Internal OAuth client admits only members of the Workspace that owns it; an outside guest can hold real read access in Google + and still get no Buzz surface. +- Propagation is poll-bound, not immediate, and offline staleness is bounded only by the 24-hour ceiling. +- Membership loss purges the local cache while the user's Google access is unchanged, so the calendar returns intact at the next + connect or mapping. +- Buzz gains a second authorization system beside the relay, which `VISION.md:37` does not anticipate. The fork accepts that tension + rather than claiming the model is intact. +- T12's "refresh failure surfaces a reconnect state" is narrowed here to the terminal class; T12's traceability table follows decision + 8's four states. +- The filename resolves the plan's `2026-09-xx-calendar-authorization.md`; the plan text is left alone, because other wave-1 tickets + share that file. From 7f3b83870dd9623c3394fbe123e09082b06a98a6 Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:37:40 -0700 Subject: [PATCH 10/11] docs: make the calendar authorization fence atomic and bound the cache Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 144 +++++++++--------- 1 file changed, 72 insertions(+), 72 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index cad1c289b65..8062a542028 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -1,88 +1,88 @@ # Calendar authorization contract (T11) -Date: 2026-09-04. Owner ticket: T11 `docs/calendar-authz`; implemented by T12 `feat/google-calendar` and assumed by T12a. Buzz renders -a Google Workspace calendar the viewer already has access to: it owns no calendar and grants no access, so effective access is the -intersection of Buzz channel membership and Google's calendar ACL, enforced at each refresh and not at render — a cached paint -outlives membership to decision 6's ceiling. Upstream RFC #3227 supplies decision 9 and nothing else. +Date: 2026-09-04. Ticket T11 `docs/calendar-authz`; implemented by T12 `feat/google-calendar`, assumed by T12a. Buzz renders a Google +Workspace calendar the viewer already has access to and grants none itself, so effective access is the intersection of Buzz channel +membership and Google's ACL, checked at refresh, not at render — a cached paint outlives membership to decision 6's ceiling. ## Decisions -**1. OAuth scopes and callback correlation.** Decision: installed-app PKCE code flow, loopback redirect, Buzz-only Cloud project -(Internal, Production); scopes `openid`, `userinfo.email` and `calendar.events`, which already grants read and write — no -`calendar.events.readonly`, no `calendarList` scope or call. The exchange reads the token response's granted scopes and writes no -binding on a partial grant. The listener bounds bytes, sockets and read time and carries a CSPRNG `state`: one flow in flight, newest -wins, and a callback whose `state` is missing or unequal is rejected before any exchange. A CSPRNG `nonce` goes out with the request -and must come back in the ID token. Reason: PKCE protects the code in transit, not its origin; PR #1382 already did this -(`6d4f7f796:desktop/src-tauri/src/commands/calendar.rs:181,261`). Tests: `google_calendar_rejects_mismatched_state`, -`google_calendar_requires_nonce_echo`. +**1. OAuth request values and callback correlation.** Decision: installed-app PKCE code flow, loopback redirect, Buzz-only Cloud project +(Internal, Production). Wire scopes: `openid`, `email` (or `https://www.googleapis.com/auth/userinfo.email`) and +`https://www.googleapis.com/auth/calendar.events`, which already grants read and write; no `calendar.events.readonly` or `calendarList`. +The request carries `access_type=offline`, and `prompt=consent` when no refresh token is held; an exchange returning no refresh token, or +scopes short of those three, writes no binding. The listener bounds bytes, sockets and read time and carries a CSPRNG `state`: one flow in +flight, newest wins, a callback whose `state` is missing or unequal rejected before any exchange. A CSPRNG `nonce` must come back in the +ID token. Reason: PKCE protects the code, not its origin; PR #1382 did this +(`6d4f7f796:desktop/src-tauri/src/commands/calendar.rs:181,261`). Tests: `rejects_mismatched_state` and `requires_nonce_echo`. -**2. Google account to Buzz identity.** Decision: one Google account per identity per installation, identity pubkey hex to the OIDC -`sub` of an ID token validated for signature (cached JWKS), `iss`, `aud`, `exp` and decision 1's `nonce`. Before the binding is -written the flow re-reads the active identity and refuses if the pubkey that began the flow is no longer active — `identity.rs:337` -can replace it mid-flow, and `dms.rs:42` is the in-repo fail-closed precedent for a captured scope re-checked at callback time. -`SecretStore` keeps tokens, scopes, `sub`, email, issuing `client_id` and a CSPRNG `binding_generation`; no token reaches the webview. -Reason: PKCE protects the code, not the JWT. Test: `google_calendar_refuses_binding_after_identity_swap`. +**2. Google account to Buzz identity.** Decision: one Google account per identity per installation, identity pubkey hex to the OIDC `sub` +of an ID token validated for signature (cached JWKS), `iss`, `aud`, `exp` and decision 1's `nonce`. Before writing the binding the flow +re-reads the active identity and refuses if the starting pubkey is gone — `import_identity` (`identity.rs:337`) replaces it at +`identity.rs:423-425`; `dms.rs:52-55` is the in-repo precedent for that re-check at callback time. `SecretStore` keeps tokens, scopes, +`sub`, email, `client_id` and a CSPRNG `binding_generation`; no token reaches the webview. Test: `refuses_binding_after_identity_swap`. -**3. Calendar ownership and sharing.** Decision: a secondary calendar owned by one named durable Workspace role account — such a -calendar has exactly one owner and dies with that account, so offboarding it needs an ownership transfer first — shared only in -Google's own ACL, which Buzz never reads, writes or reconciles. Affordances come from the `accessRole` in the mapped calendar's -`events.list`, narrowed by event type and organizer; an unrecognized role is read-only. Reason: a calendar role does not make every -event mutable. +**3. Calendar ownership and sharing.** Decision: a secondary calendar owned by one durable Workspace role account named in T12 — +`buzz-calendar@`, never a person: such a calendar has one owner and dies with the account, so offboarding needs an +ownership transfer first. It is shared only in Google's ACL, which Buzz never reads, writes or reconciles. Affordances come from the +`accessRole` in the mapped calendar's `events.list`, narrowed by event type and organizer; an unrecognized role is read-only. -**4. Which channels show it, and who chooses.** Decision: opt-in per channel, keyed by (pubkey, normalized relay URL, channel id) — -not `Community.id`, a local mutable field separate from the authoritative `relayUrl` (`types.ts:1-4`), so a relay repoint cannot carry -the calendar into a channel on another relay; `channelSnapshot.ts:45-46` is the repo precedent for authorization-sensitive persisted -keys. Stored locally per identity; the admin conveys the calendar out of band. The calendar id is typed into the native confirm, -naming account, calendar and channel. Reason: the mapping carries no authority. +**4. Which channels show it, who chooses, and what the webview may ask for.** Decision: opt-in per channel, keyed by (pubkey, normalized +relay URL, channel id) — not `Community.id`, a local mutable field separate from the authoritative `relayUrl` (`types.ts:1-4`), so a relay +repoint cannot carry the calendar to another relay's channel; `channelSnapshot.ts:45-53` is the repo precedent for such keys. Stored +locally per identity; the admin conveys the calendar out of band and its id is typed into a native confirm naming account, calendar and +channel. Every native calendar command takes an opaque mapping id and resolves the calendar id natively; none accepts a calendar id plus +event id from the webview as authority. Every mutating command — create, edit, delete — revalidates the whole tuple at call time: pubkey, +normalized relay URL, channel membership, binding generation, mapped calendar, Google write authority. An offline or stale view is +read-only. Reason: the mapping carries no authority, and a renderer that could name a calendar would spend the stored token. Test: +`edit_rejected_after_membership_removal` invokes the command directly, not the disabled button. -**5. Disconnect behavior.** Decision: the binding and its revocation journal entry (refresh token, binding key, generation, purge -predicate, deadline) are one value under one key, so a single `store_all` writes the entry and clears the binding in one mutation — -`mutate_blob` is private (`secret_store.rs:395`), `store_all` (601) only inserts or overwrites, `delete` (901) is a separate mutation. -Then the purge, then Google's revocation endpoint. Only HTTP 200 clears the entry; anything else retries with backoff to a seven-day -ceiling and then stops in a terminal `revocation_unconfirmed` state the settings surface names. Reason: Rule 5 leaves no torn prefix; -`invalid_token` does not prove the grant is gone, and a journal with no terminal state never converges after a crash past a 200. +**5. Disconnect and the revocation journal.** Decision: one envelope per identity, under one key, holds `active_binding: Option<_>` and a +bounded map of pending revocations keyed by generation, capacity 8; reconnect proceeds while entries pend, and a ninth retires the oldest +to the terminal state below. Disconnect is one `store_all`: it clears the binding and writes the entry (refresh token, key, generation, +purge predicate, deadline) in one mutation (`secret_store.rs`: `mutate_blob` private 395, `store_all` 601 overwrite-only, `delete` 901 +separate). Then the purge, then Google's revocation endpoint. An entry clears only when `purge_confirmed` and `revocation_confirmed` both +hold, and only HTTP 200 sets the second; anything else backs off to a seven-day ceiling, then stops in a terminal `revocation_unconfirmed` +state the settings surface names. Reason: Rule 5 leaves no torn prefix; a journal with no terminal state never converges. -**6. Cached event data on disconnect and membership loss.** Decision: a SQLite render cache outside the archive DB, capped at 5,000 -rows per (identity, calendar) and evicted oldest first, keyed by (pubkey, community, binding generation, `sub`, membership epoch, -calendar id, event id); membership loss bumps the epoch. Purged on disconnect, on membership loss, and on decision 8's terminal class, -`invalid_grant` included. Only a verified `events.list` failure — 403 or 404 on the mapped calendar, not a transport error — purges -for access loss; anything else stays stale to a 24-hour ceiling. One rule fences every asynchronous write: a token write, a cache -write and a view delivery each compare their captured (identity pubkey, community, binding generation, `sub`, membership epoch) -against live state at completion and drop on mismatch, so a late refresh cannot resurrect a credential and a late `events.list` cannot -repopulate the cache (`dms.rs:42` again). Reason: T12 requires the purge, and a purge no write fences is undone by a race. +**6. Cached event data, its bounds, and the write fence.** Decision: a SQLite render cache outside the archive DB, keyed by (pubkey, +community, binding generation, `sub`, membership epoch, calendar id, event id). `community` is the normalized relay URL in every cache and +fence tuple; the epoch is a persisted, monotonic counter never reused across a restart or relay repoint, bumped before the purge on +membership loss. Bounds: per (identity, calendar) partition 5,000 rows and 16 MiB; globally 128 MiB and 64 partitions; 256 KiB per row; +oldest-first eviction in a partition, LRU across partitions; one fetch 90 days ahead and 30 back, 10 pages, 8 MiB, a 30-second deadline, +backoff capped at five minutes. Purged on disconnect, on membership loss, and on decision 8's terminal class; only its ACL 403 or 404 on +the mapped calendar purges for access loss, anything else staying stale to the 24-hour ceiling. One rule fences every asynchronous write: +a token, journal or binding write commits inside one `SecretStore` mutation, and only if the value under decision 5's envelope key still +matches `Active { generation: G, .. }` for the captured G — a compare-and-swap inside that one mutation, never a compare then `store_all`. +Cache writes stay generation- and epoch-addressed and readers select only the current tuple, so a late `events.list` writes rows no reader +reaches. Reason: check-then-write loses the race the purge exists for (`dms.rs:52-55`). Tests, each barrier-held and each failing when the +compare-and-swap is removed: `refresh_after_disconnect_does_not_restore_binding` and `events_list_after_membership_loss_is_unreadable`; +`stale_rows_unreadable_after_restart` restarts with membership lost and rows on disk. -**7. Revocation propagation timing.** Decision: poll-bound — refresh on channel focus and at most five minutes apart while the surface -is visible, with backoff. Healthy path: a principal removed in Google keeps the view for one poll interval at worst, or until next -open. Degraded path — a transient failure held behind a stale view per decision 8, or offline — is bounded by the 24-hour ceiling, not -by the poll interval. Reason: a webhook needs a public callback the fork does not want. +**7. Revocation propagation timing.** Decision: poll-bound — refresh on channel focus and at most five minutes apart while the surface is +visible, with backoff. A principal removed in Google keeps the view for one poll interval at worst; a transient failure behind a stale +view, and an offline client, are bounded by the 24-hour ceiling instead. Reason: a webhook needs a public callback. -**8. Refresh-token failure UX.** Decision: transient (network, 5xx, 429, quota 403s) backs off behind a stale view; `unreachable` past -the 24-hour ceiling drops events, offering Retry; terminal (`invalid_grant`, a withdrawn scope, a 401 surviving one forced refresh) -purges per decision 6 and offers Reconnect; `invalid_client` is `app_error` with neither. Reason: Rules 6 and 4; honest state names. +**8. Refresh failure states and the Google error matrix.** Decision: four states, chosen by HTTP status plus error reason, stated once for +the whole memo. Terminal — `invalid_grant`, a withdrawn scope, a 401 surviving one forced refresh, a 404 on the mapped calendar, or a 403 +on it whose reason is `forbidden` or `insufficientPermissions` — purges per decision 6 and offers Reconnect. Transient — network, 5xx, +429, and a 403 whose reason is `rateLimitExceeded`, `userRateLimitExceeded` or `quotaExceeded` — backs off behind a stale view; past the +24-hour ceiling it becomes `unreachable`, drops events and offers Retry. `invalid_client` shows `app_error` with neither affordance, the +24-hour ceiling still applying. Disconnect stays available in every state, including `app_error`. -**9. What an agent may read or write.** Decision: nothing in v1 — no managed agent, ACP harness or MCP server gets a Google credential -and no calendar command is agent-facing. The denial seam is the credential lookup in the calendar command layer, which refuses a -managed-agent caller; a test that fails when that guard is removed covers agent read and agent write attempts separately, as T12's -traceability table requires. Later access takes RFC #3227's whole shape: a scoped vault reference in place of the user's token, one -owner approval, substitution at egress, and exclusion from both the owner-review draft and the agent sandbox. Reason: a human's grant -lets an agent act as that human outside Buzz's gate. +**9. What an agent may read or write.** Decision: nothing in v1. The Google credential lives in a human-only command module, and the +calendar commands and that credential are structurally absent from the ACP, MCP and CLI command registries and from the environment of +every spawned agent — absence is the denial seam, not a caller check a future adapter could default to "human". The guard test asserts +against those production registries and the real spawn environment, not a constructed caller enum, and covers agent read and agent write +separately, as T12's traceability table requires. Later access takes RFC #3227's whole shape: a scoped vault reference in place of the +user's token, one owner approval, substitution at egress, and exclusion from both the owner-review draft and the agent sandbox. Reason: a +human's grant lets an agent act as that human outside Buzz's gate. ## Risks accepted -- Revocation is Cloud-project-wide and cross-device: Disconnect on one machine ends the grant on every machine, and the other learns - of it as `needs_reconnect` at its next refresh. -- A sign-out wipe attempts every pending revocation under one deadline and then proceeds; a grant it could not revoke stays live, and - the confirm names that account. -- The v1 mapping is a local per-user setting, so the admin's choice is a convention no client enforces. Enforcing it needs a - relay-allow-listed kind this fork cannot add. -- The Internal OAuth client admits only members of the Workspace that owns it; an outside guest can hold real read access in Google - and still get no Buzz surface. -- Propagation is poll-bound, not immediate, and offline staleness is bounded only by the 24-hour ceiling. -- Membership loss purges the local cache while the user's Google access is unchanged, so the calendar returns intact at the next - connect or mapping. -- Buzz gains a second authorization system beside the relay, which `VISION.md:37` does not anticipate. The fork accepts that tension - rather than claiming the model is intact. -- T12's "refresh failure surfaces a reconnect state" is narrowed here to the terminal class; T12's traceability table follows decision - 8's four states. -- The filename resolves the plan's `2026-09-xx-calendar-authorization.md`; the plan text is left alone, because other wave-1 tickets - share that file. +- Revocation is Cloud-project-wide: Disconnect on one machine ends the grant everywhere, and others learn of it as `needs_reconnect` at + next refresh. A sign-out wipe tries every pending revocation under one deadline, then proceeds; any it misses stays live. +- The v1 mapping is local per-user state, so the admin's choice is a convention no client enforces without a relay-allow-listed kind. +- The Internal OAuth client admits only members of the owning Workspace, so an outside guest with real Google access gets no Buzz surface; + conversely membership loss purges the cache while Google access is untouched, so the calendar returns at the next connect. +- Buzz gains a second authorization system beside the relay, which `VISION.md:37` does not anticipate; the fork accepts that tension. +- T12's "refresh failure surfaces a reconnect state" narrows to decision 8's terminal class; the traceability table follows all four. From 69514b41ef75c121104f88dc278a88bd2c708f47 Mon Sep 17 00:00:00 2001 From: wiggdevin <202901685+wiggdevin@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:01:16 -0700 Subject: [PATCH 11/11] docs: transition-specific fences, revocation-before-reconnect, and the agent trust boundary Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- .../2026-09-04-calendar-authorization.md | 140 +++++++++--------- 1 file changed, 71 insertions(+), 69 deletions(-) diff --git a/docs/plans/2026-09-04-calendar-authorization.md b/docs/plans/2026-09-04-calendar-authorization.md index 8062a542028..979c650265e 100644 --- a/docs/plans/2026-09-04-calendar-authorization.md +++ b/docs/plans/2026-09-04-calendar-authorization.md @@ -1,88 +1,90 @@ # Calendar authorization contract (T11) -Date: 2026-09-04. Ticket T11 `docs/calendar-authz`; implemented by T12 `feat/google-calendar`, assumed by T12a. Buzz renders a Google -Workspace calendar the viewer already has access to and grants none itself, so effective access is the intersection of Buzz channel -membership and Google's ACL, checked at refresh, not at render — a cached paint outlives membership to decision 6's ceiling. +Date: 2026-09-04. T11 `docs/calendar-authz`, implemented by T12 `feat/google-calendar`, assumed by T12a. Buzz renders a Google Workspace calendar the +viewer already has and grants none itself: access is Buzz channel membership intersected with Google's ACL, checked at refresh, not at render, so a cached +paint outlives membership to decision 6's `stale_after`. ## Decisions -**1. OAuth request values and callback correlation.** Decision: installed-app PKCE code flow, loopback redirect, Buzz-only Cloud project -(Internal, Production). Wire scopes: `openid`, `email` (or `https://www.googleapis.com/auth/userinfo.email`) and -`https://www.googleapis.com/auth/calendar.events`, which already grants read and write; no `calendar.events.readonly` or `calendarList`. -The request carries `access_type=offline`, and `prompt=consent` when no refresh token is held; an exchange returning no refresh token, or -scopes short of those three, writes no binding. The listener bounds bytes, sockets and read time and carries a CSPRNG `state`: one flow in -flight, newest wins, a callback whose `state` is missing or unequal rejected before any exchange. A CSPRNG `nonce` must come back in the -ID token. Reason: PKCE protects the code, not its origin; PR #1382 did this -(`6d4f7f796:desktop/src-tauri/src/commands/calendar.rs:181,261`). Tests: `rejects_mismatched_state` and `requires_nonce_echo`. +**1. OAuth request values and callback correlation.** Decision: installed-app PKCE code flow, loopback redirect, Buzz-only Cloud project (Internal, +Production). Scopes `openid`, `email`, `calendar.events`, which already grants read and write; no `calendar.events.readonly` or `calendarList`. +`access_type=offline`, plus `prompt=consent` when no refresh token is held; an exchange without a refresh token, or short of those scopes, writes no +binding. The listener bounds bytes, sockets and read time; one flow in flight, newest wins, and a callback whose CSPRNG `state` is missing or unequal is +rejected before exchange. A CSPRNG `nonce` must come back in the ID token. Reason: PKCE protects the code, not its origin (`calendar.rs:181,261` at +`6d4f7f796`). Tests: `rejects_mismatched_state`, `requires_nonce_echo`. -**2. Google account to Buzz identity.** Decision: one Google account per identity per installation, identity pubkey hex to the OIDC `sub` -of an ID token validated for signature (cached JWKS), `iss`, `aud`, `exp` and decision 1's `nonce`. Before writing the binding the flow -re-reads the active identity and refuses if the starting pubkey is gone — `import_identity` (`identity.rs:337`) replaces it at -`identity.rs:423-425`; `dms.rs:52-55` is the in-repo precedent for that re-check at callback time. `SecretStore` keeps tokens, scopes, -`sub`, email, `client_id` and a CSPRNG `binding_generation`; no token reaches the webview. Test: `refuses_binding_after_identity_swap`. +**2. Google identity binding and credential redaction.** Decision: one Google account per identity per installation, identity pubkey hex to the OIDC `sub` +of an ID token validated for signature (cached JWKS), `iss`, `aud`, `exp` and decision 1's `nonce`. Before writing, the flow re-reads the active identity +and refuses if the starting pubkey is gone: `import_identity` (`identity.rs:337`) replaces it at `identity.rs:423-425`; `dms.rs:52-55` is the precedent for +re-checking a captured scope. `SecretStore` keeps tokens, scopes, `sub`, email, `client_id` and a CSPRNG `binding_generation`; no token reaches the +webview. Access, refresh and ID tokens, authorization codes, PKCE verifiers and callback URLs are wrapped in a redacting type; boundary errors are +sanitized. Tests: `refuses_binding_after_identity_swap`; a table of signature, `iss`/`aud`/`exp`, `nonce`, scope and refresh-token failures through the +production validator; and one sentinel per credential, asserting none reaches logs, command errors, UI payloads or `Debug` output. -**3. Calendar ownership and sharing.** Decision: a secondary calendar owned by one durable Workspace role account named in T12 — -`buzz-calendar@`, never a person: such a calendar has one owner and dies with the account, so offboarding needs an -ownership transfer first. It is shared only in Google's ACL, which Buzz never reads, writes or reconciles. Affordances come from the -`accessRole` in the mapped calendar's `events.list`, narrowed by event type and organizer; an unrecognized role is read-only. +**3. Calendar ownership and sharing.** Decision: a secondary calendar owned by a durable Workspace role account, `buzz-calendar@`, never +a person: one owner, and it dies with the account, so offboarding needs an ownership transfer first. Naming it and having the Workspace admin verify it is +an acceptance gate T12 cannot start without. Sharing lives only in Google's ACL, which Buzz never touches; affordances come from the `accessRole` in the +mapped `events.list`, narrowed by event type and organizer, and an unrecognized role is read-only. -**4. Which channels show it, who chooses, and what the webview may ask for.** Decision: opt-in per channel, keyed by (pubkey, normalized -relay URL, channel id) — not `Community.id`, a local mutable field separate from the authoritative `relayUrl` (`types.ts:1-4`), so a relay -repoint cannot carry the calendar to another relay's channel; `channelSnapshot.ts:45-53` is the repo precedent for such keys. Stored -locally per identity; the admin conveys the calendar out of band and its id is typed into a native confirm naming account, calendar and -channel. Every native calendar command takes an opaque mapping id and resolves the calendar id natively; none accepts a calendar id plus -event id from the webview as authority. Every mutating command — create, edit, delete — revalidates the whole tuple at call time: pubkey, -normalized relay URL, channel membership, binding generation, mapped calendar, Google write authority. An offline or stale view is -read-only. Reason: the mapping carries no authority, and a renderer that could name a calendar would spend the stored token. Test: -`edit_rejected_after_membership_removal` invokes the command directly, not the disabled button. +**4. Which channels show it; what the webview may ask.** Decision: opt-in per channel, keyed by (pubkey, normalized relay URL, channel id) — not +`Community.id`: `id` and `relayUrl` are separate fields (`types.ts:1-4`) and `relayUrl` is user-mutable (`useCommunities.tsx:54-56`), so keying on it would +let a relay repoint carry the calendar elsewhere; `channelSnapshot.ts:45-53` is the precedent. Stored per identity; the admin conveys the calendar out of +band; its id is typed into a native confirm naming account, calendar and channel. Native commands take an opaque mapping id and resolve the calendar id +themselves; none accepts a calendar id from the webview as authority. Create, edit and delete revalidate the whole tuple at call time: pubkey, relay URL, +membership, binding generation, mapped calendar, Google write authority. An offline or stale view is read-only. Reason: a renderer that could name a +calendar would spend the token. Tests invoke read, create, edit and delete directly after membership removal, not the disabled button. -**5. Disconnect and the revocation journal.** Decision: one envelope per identity, under one key, holds `active_binding: Option<_>` and a -bounded map of pending revocations keyed by generation, capacity 8; reconnect proceeds while entries pend, and a ninth retires the oldest -to the terminal state below. Disconnect is one `store_all`: it clears the binding and writes the entry (refresh token, key, generation, -purge predicate, deadline) in one mutation (`secret_store.rs`: `mutate_blob` private 395, `store_all` 601 overwrite-only, `delete` 901 -separate). Then the purge, then Google's revocation endpoint. An entry clears only when `purge_confirmed` and `revocation_confirmed` both -hold, and only HTTP 200 sets the second; anything else backs off to a seven-day ceiling, then stops in a terminal `revocation_unconfirmed` -state the settings surface names. Reason: Rule 5 leaves no torn prefix; a journal with no terminal state never converges. +**5. Disconnect and the revocation journal.** Decision: one envelope per identity holds `active_binding: Option<_>` and a map of pending revocations keyed +by generation, capacity 8. Disconnect is one mutation: it clears the binding and writes the entry (refresh token, key, generation, purge predicate, +deadline) (`secret_store.rs:395` `mutate_blob`; 601 `store_all` is overwrite-only, 901 `delete` separate); then the purge, then Google's revocation +endpoint. An entry clears only when `purge_confirmed` and `revocation_confirmed` both hold; only HTTP 200 sets the second, and anything else backs off to a +seven-day ceiling, then a terminal `revocation_unconfirmed` state settings names. While an entry for (`client_id`, `sub`) is retryable, Connect for that +`sub` is refused with a named state; the user may explicitly abandon it in settings before a new binding H is issued. Terminal entries stay in the map and +count toward the cap, cleared only by the user; a ninth is refused, Connect blocked with a named state, rather than retiring one. Reason: revocation is +project-wide, so a late 200 for G would kill H; a journal with no terminal state never converges. Tests: failed revoke, then reconnect refused; abandon, +reconnect, then a delayed 200 for G leaves H untouched, since an abandoned entry never retries. -**6. Cached event data, its bounds, and the write fence.** Decision: a SQLite render cache outside the archive DB, keyed by (pubkey, -community, binding generation, `sub`, membership epoch, calendar id, event id). `community` is the normalized relay URL in every cache and -fence tuple; the epoch is a persisted, monotonic counter never reused across a restart or relay repoint, bumped before the purge on -membership loss. Bounds: per (identity, calendar) partition 5,000 rows and 16 MiB; globally 128 MiB and 64 partitions; 256 KiB per row; -oldest-first eviction in a partition, LRU across partitions; one fetch 90 days ahead and 30 back, 10 pages, 8 MiB, a 30-second deadline, -backoff capped at five minutes. Purged on disconnect, on membership loss, and on decision 8's terminal class; only its ACL 403 or 404 on -the mapped calendar purges for access loss, anything else staying stale to the 24-hour ceiling. One rule fences every asynchronous write: -a token, journal or binding write commits inside one `SecretStore` mutation, and only if the value under decision 5's envelope key still -matches `Active { generation: G, .. }` for the captured G — a compare-and-swap inside that one mutation, never a compare then `store_all`. -Cache writes stay generation- and epoch-addressed and readers select only the current tuple, so a late `events.list` writes rows no reader -reaches. Reason: check-then-write loses the race the purge exists for (`dms.rs:52-55`). Tests, each barrier-held and each failing when the -compare-and-swap is removed: `refresh_after_disconnect_does_not_restore_binding` and `events_list_after_membership_loss_is_unreadable`; -`stale_rows_unreadable_after_restart` restarts with membership lost and rows on disk. +**6. Cached event data, its bounds, and the write fences.** Decision: a SQLite render cache outside the archive DB, keyed by (pubkey, community, binding +generation, `sub`, membership epoch, calendar id, event id); `community` is the normalized relay URL, the epoch a persisted monotonic counter never reused +across restart or repoint, bumped before the purge on membership loss. Bounds count database plus WAL and temp files: per (identity, calendar) partition +5,000 rows and 16 MiB; globally 128 MiB and 64 partitions; 256 KiB a row; oldest-first eviction in a partition, LRU across them, a WAL checkpoint after +every eviction and a forced one at 32 MiB of WAL no reader may defer; one fetch 90 days ahead and 30 back, 10 pages, 8 MiB, a 30-second deadline, backoff +capped at five minutes. `stale_after` is persisted and absolute: the last successful authorization refresh plus 24 hours, never extended by failure or +restart. Purge on disconnect, on membership loss and on decision 8's terminal class; for access loss only an ACL 403 or 404 on the calendar. Every token, +journal and binding write commits inside one `SecretStore` mutation (`secret_store.rs:395`) under a transition-specific predicate, not one universal rule: +refresh requires `active_binding == Active{G}` for the captured G; disconnect requires `Active{G}` and writes `None` plus pending G; journal progress +requires pending G at its expected revision counter; initial or reconnect requires `active_binding == None`, its own OAuth-flow generation and an unchanged +identity pubkey. Cache writes stay generation- and epoch-addressed and readers select only the current tuple, so a late `events.list` writes rows nobody +reads. Reason: check-then-write loses the race the purge exists for; one universal predicate cannot advance a cleared binding's journal. One barrier-held +test per transition, each failing when its predicate is removed, plus `stale_rows_unreadable_after_restart` either side of `stale_after`. -**7. Revocation propagation timing.** Decision: poll-bound — refresh on channel focus and at most five minutes apart while the surface is -visible, with backoff. A principal removed in Google keeps the view for one poll interval at worst; a transient failure behind a stale -view, and an offline client, are bounded by the 24-hour ceiling instead. Reason: a webhook needs a public callback. +**7. Revocation propagation timing.** Decision: poll-bound — refresh on focus, at most five minutes apart while visible, with backoff; a removed principal +keeps the view one poll interval, and transient failure and offline are bounded by `stale_after`. Reason: a webhook needs a public callback. -**8. Refresh failure states and the Google error matrix.** Decision: four states, chosen by HTTP status plus error reason, stated once for -the whole memo. Terminal — `invalid_grant`, a withdrawn scope, a 401 surviving one forced refresh, a 404 on the mapped calendar, or a 403 -on it whose reason is `forbidden` or `insufficientPermissions` — purges per decision 6 and offers Reconnect. Transient — network, 5xx, -429, and a 403 whose reason is `rateLimitExceeded`, `userRateLimitExceeded` or `quotaExceeded` — backs off behind a stale view; past the -24-hour ceiling it becomes `unreachable`, drops events and offers Retry. `invalid_client` shows `app_error` with neither affordance, the -24-hour ceiling still applying. Disconnect stays available in every state, including `app_error`. +**8. Refresh failure states and the error matrices.** Decision: four states by HTTP status plus error reason, over three total matrices — token exchange +and refresh, `events.list`, mutations — each with a default branch. Terminal — `invalid_grant`, a withdrawn scope, a 401 surviving a forced refresh, a 404 +on the mapped calendar, or a 403 reading `forbidden` or `insufficientPermissions` — purges per decision 6, offering Reconnect. Transient — network, 5xx, +429, a 403 reading `rateLimitExceeded`, `userRateLimitExceeded` or `quotaExceeded` — backs off behind a stale view; past `stale_after` it becomes +`unreachable`, drops events, offers Retry. `invalid_client` shows `app_error`, neither affordance. Refresh and list defaults fail closed at `stale_after`; +the mutation default, `forbiddenForNonOrganizer` included, rejects that command alone, changing no global state. Disconnect stays available throughout. -**9. What an agent may read or write.** Decision: nothing in v1. The Google credential lives in a human-only command module, and the -calendar commands and that credential are structurally absent from the ACP, MCP and CLI command registries and from the environment of -every spawned agent — absence is the denial seam, not a caller check a future adapter could default to "human". The guard test asserts -against those production registries and the real spawn environment, not a constructed caller enum, and covers agent read and agent write -separately, as T12's traceability table requires. Later access takes RFC #3227's whole shape: a scoped vault reference in place of the -user's token, one owner approval, substitution at egress, and exclusion from both the owner-review draft and the agent sandbox. Reason: a -human's grant lets an agent act as that human outside Buzz's gate. +**9. What an agent may read or write.** Decision: nothing in v1. The credential lives in a human-only module; it and the calendar commands are absent from +the ACP, MCP and CLI registries and from every spawned agent's environment — absence is the denial seam, not a caller check a future adapter could default +to "human". Managed agents run at operator trust with an unrestricted shell, so that seam protects the credential and the commands, not rendered rows on +disk (see Risks). The guard test drives the production shell and file tools, asserting they can neither reach the credential nor invoke a calendar command, +read or write. Later access takes RFC #3227's shape: a scoped vault reference for the token, one owner approval, egress substitution, and exclusion from +the owner-review draft and the agent sandbox. Reason: a human's grant lets an agent act as that human outside Buzz's gate. ## Risks accepted -- Revocation is Cloud-project-wide: Disconnect on one machine ends the grant everywhere, and others learn of it as `needs_reconnect` at - next refresh. A sign-out wipe tries every pending revocation under one deadline, then proceeds; any it misses stays live. +- Managed agents run at operator trust: the shell takes a caller-chosen workdir (`buzz-dev-mcp/src/shell.rs:146`), the file layer enforces no containment + (`buzz-dev-mcp/src/paths.rs:3-7`), as `VISION_AGENT.md:57` intends. The render cache is therefore readable by any process at the user's trust level, + agents included, like every other local Buzz store; it holds no access, refresh or ID token and no authorization code, and encrypting it would not stop a + process that can already read the keychain. +- Revocation is Cloud-project-wide: Disconnect on one machine ends the grant everywhere; others learn at next refresh as `needs_reconnect`. A sign-out wipe + tries every pending revocation under one deadline, then proceeds; any it misses stays live. - The v1 mapping is local per-user state, so the admin's choice is a convention no client enforces without a relay-allow-listed kind. -- The Internal OAuth client admits only members of the owning Workspace, so an outside guest with real Google access gets no Buzz surface; - conversely membership loss purges the cache while Google access is untouched, so the calendar returns at the next connect. +- The Internal OAuth client admits only Workspace members, so a guest with real Google access gets no Buzz surface; conversely membership loss purges the + cache while Google access is untouched, so the calendar returns at the next connect. - Buzz gains a second authorization system beside the relay, which `VISION.md:37` does not anticipate; the fork accepts that tension. - T12's "refresh failure surfaces a reconnect state" narrows to decision 8's terminal class; the traceability table follows all four.