Skip to content

docs: calendar authorization contract (T11) - #2

Merged
wiggdevin merged 13 commits into
zs/mainfrom
docs/calendar-authz
Sep 5, 2026
Merged

wiggdevin merged 13 commits into
zs/mainfrom
docs/calendar-authz

Conversation

@wiggdevin

@wiggdevin wiggdevin commented Sep 4, 2026

Copy link
Copy Markdown

Summary

One-page design memo for T11: docs/plans/2026-09-04-calendar-authorization.md, 90 lines, nine decisions covering scopes and callback correlation, identity binding, calendar ownership, channel mapping, disconnect and revocation, cache bounds and write fences, propagation timing, refresh-failure UX, and agent access. Docs-only diff. It is the contract T12 (feat/google-calendar) implements and T12a assumes. The file resolves the plan's 2026-09-xx-calendar-authorization.md placeholder.

Review history

Three GPT-5.6 Sol adversarial rounds (high effort, read-only), each triaged against the tree and answered as decisions:

  • Round 1: 2 BLOCK, 8 WARN verified (loopback callback accepted an uncorrelated code: no OAuth state or OIDC nonce; binding_generation fenced reads but not writes). 3 Sol BLOCKs downgraded under the plan rubric. All answered.
  • Round 2: 1 BLOCK (the fence was compare-then-write, not a compare-and-swap under the store's mutation) plus 8 WARN (mutations after stale membership, confused-deputy calendar ids from the webview, revocation journal shape, cache bounds, 403 matrix, exact wire scopes and access_type=offline, caller provenance, epoch durability). All answered.
  • Round 3: 3 BLOCK (agents at operator trust can read a plaintext cache through the bundled shell; a delayed project-wide revocation for G kills a reconnected H; a universal "must find Active{G}" predicate cannot advance journal entries) plus 7 WARN, 1 NIT. All answered; one required the driver scope decision below.

Every file:line citation was verified with sed in the worktree, including 6d4f7f796:calendar.rs:181,261 (PR block#1382's prototype, which already carried state).

Driver decision (scope)

Managed agents run at operator trust with an unrestricted shell (crates/buzz-dev-mcp/src/shell.rs:146, paths.rs:3-7, VISION_AGENT.md:57). The denial seam in decision 9 therefore protects the Google credential and the calendar commands, not rendered event rows on disk; the render cache is readable by any process at the user's trust level like every other local Buzz store, never holds a token or code, and encrypting it would not stop a process that can already read the keychain. Recorded under Risks accepted.

For T12

  • Test names fixed by this memo (rejects_mismatched_state, requires_nonce_echo, refuses_binding_after_identity_swap, refresh_after_disconnect_does_not_restore_binding, events_list_after_membership_loss_is_unreadable, stale_rows_unreadable_after_restart, edit_rejected_after_membership_removal, and the per-transition fence tests) belong in T12's traceability table in the implementation plan; the plan file is not edited here.
  • Naming and Workspace-admin verification of the owning calendar account is an acceptance gate T12 cannot start without.
  • The memo's decision 8 narrows T12's "refresh failure surfaces a reconnect state" to the terminal class; T12's traceability table follows the four states.

Test plan

  • just file-size-check exit 0; memo is 90 lines (cap 90).
  • Pre-push lefthook lanes green on push (docs-only diff).
  • No code, no Cargo.lock, no workflow changes.

🤖 Generated with Claude Code

wiggdevin and others added 8 commits September 4, 2026 05:35
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 block#3227's separate scoped principal.

Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
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 block#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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…tion

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
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 block#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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
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>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
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>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
@wiggdevin

Copy link
Copy Markdown
Author

Converted to draft by the driver on 2026-09-04.

Four Sol passes have not converged: pass 4 still reports 7 BLOCK and 5 WARN (OIDC identity validation, invalid_token semantics, crash-safe account replacement, membership-loss retention vs T12, unused scope, mapping existence oracle, callback server bounds). The memo has grown to 990 lines, which is a full OAuth integration spec rather than the one-page decision contract ticket T11 asked for.

Decision: T11 is not complete. Before wave 3 (when T12 needs it) the memo is cut back to the nine checklist decisions with one reason each, the implementation-level material moves into T12's own design, and Sol reviews the short form. Pass-4 report: scratchpad wave1/calendar-authz/sol-review-pass4.md (local).

🤖 Generated with Claude Code

Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
…e agent trust boundary

Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
@wiggdevin
wiggdevin marked this pull request as ready for review September 5, 2026 02:02
@wiggdevin
wiggdevin enabled auto-merge September 5, 2026 02:02
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant