Skip to content

feat(desktop): bounded google calendar event contract and API walk (T12, slice one) - #21

Merged
wiggdevin merged 6 commits into
zs/mainfrom
feat/google-calendar
Sep 5, 2026
Merged

wiggdevin merged 6 commits into
zs/mainfrom
feat/google-calendar

Conversation

@wiggdevin

@wiggdevin wiggdevin commented Sep 5, 2026

Copy link
Copy Markdown

feat(desktop): bounded google calendar event contract and API walk (T12, slice one)

Summary

This PR lands slice one of T12 (Google Calendar integration): the Rust
contract and data layer only. It adds a google_calendar module to
desktop/src-tauri with:

  • OAuth authorization contract (T11 dependency) — PKCE, state/nonce
    correlation, a loopback callback listener with a bounded total wait,
    and a revocation journal with bounded retry.
  • A fallible compare-and-set mutation seam on the secret store
    (SecretStore::mutate_checked), used by the calendar credential
    envelope.
  • A bounded Calendar REST client: capped DTOs (event id, etag, time
    zone, page token, id token), a proven-interval walk over paginated
    events.list results, transport containments (origin pin, no
    redirects, no environment proxy, redacted errors), and a
    classify-and-retry failure model.
  • An events-URL egress-guard inventory update (the repo's existing
    relay-egress tripwire) recording the three new /events
    URL-construction sites this module adds.

No UI, no Tauri command registration, no render cache. Those are T11
decisions 4, 6 (cache) and 7, and T12a's frontend decisions — all out
of scope for this slice; see Follow-ups.

Fork deviations

  • Chose the finding's primary resolution (three inventory rows) over its alternative (wire assert_no_key_backup_bytes in single_event_call). Reason cited in code and log: egress_guard.rs:1-24 scopes the guard to relay-bound egress of NIP-49 material, and the Calendar collection built at client.rs:604-607 resolves against www.googleapis.com with a Google bearer token over the Calendar REST API — no Nostr event, no relay, no websocket, so assert_no_key_backup would have nothing to check. The tripwire fired on a textual coincidence: its needle is the literal string '/events', which the Calendar REST collection happens to share with the Nostr relay route. Wiring the guard would also not close W5, whose defect is an unbounded body with no writable-field allowlist (client.rs:472, client.rs:509); an ncryptsec1 substring check bounds neither size nor field set, and the size cap plus allowlist are already scheduled for slice two.
  • The ticket's stated evals (cargo test google_calendar, just desktop-test) never reach egress_guard::tests, which is why the build round did not see this failure. This round therefore runs the whole lane — cargo test --lib and just desktop-tauri-test — as gates rather than only the ticket filters, and both are recorded above.

Defect checklist

  1. Cap every relay-/user-sourced string at the DTO and test the cap — NOT APPLICABLE to this diff: it changes one test-only const table and adds no DTO field, no parse, no input path. Unchanged from the build round, where every cap named in its finding 10 (MAX_ID_CHARS, MAX_ETAG_CHARS, MAX_TIME_ZONE_CHARS, MAX_PAGE_TOKEN_CHARS, MAX_ID_TOKEN_BYTES) has a test.
  2. Order multi-step writes so every prefix is consistent — NOT APPLICABLE: no write, durable or in-memory, is added or reordered.
  3. Explicit environment for every child process or external server — NOT APPLICABLE: nothing is spawned and no server is started. The change is a compile-time table read by an in-process file scan (read_src_files over CARGO_MANIFEST_DIR).
  4. Every gate bounds the quantity that actually costs — MET, and this is the substance of the fix. The tripwire's costing quantity is the per-file count of /events URL-construction sites paired with the count of guard calls in that file — the unguarded egress site itself, not a proxy such as file-is-on-a-list. The new rows keep that pairing exact at (1,0), (3,0), (1,0) instead of widening the scan, so a second site in client.rs or a fourth in client_tests.rs trips it again.
  5. For every guard, a test that fails when the guard is removed — MET. The guard here is the inventory scan; removing any of the three new rows reproduces the pre-fix FAILED output exactly, and the pairing logic's own falsifiability is proven by inventory_scan_catches_new_site_in_allowlisted_file, inventory_scan_catches_removed_guard_call and inventory_scan_catches_new_unlisted_file, all re-run green.
  6. No swallowed failure — MET. events_inventory_violations collects every violating file and events_url_inventory_is_fully_guarded asserts on the joined list (egress_guard_tests.rs:381-388), so no file is skipped and no drift is downgraded to a warning. The fix adds no error path.

Gates

Tested base OID (origin/zs/main, before this PR's merge commit): 347bfeea5e4f50a4640132d8d750b7d312763d2a

Command Result
cargo test google_calendar -- --list | grep -c ': test' then cargo test google_calendar 67 listed (>= ticket bar of 5); 67 passed, 0 failed
just desktop-test 6452 passed, 0 failed
cargo test --lib (desktop/src-tauri) 3387 passed, 0 failed
just fmt-check exit 0
just clippy exit 0
just desktop-check exit 0
just desktop-tauri-fmt-check exit 0
just desktop-tauri-clippy exit 0
just file-size-check exit 0
desktop/src-tauri/Cargo.lock diff none — no Rust dependency added, no restore needed
unwrap()/expect()/unsafe scan on production files none introduced
DCO (git rev-list origin/zs/main..HEAD × git show -s --format=%B) all 5 commits carry Signed-off-by, all carry Co-Authored-By: Claude Fable 5.1

Pre-push gate suite (run at push time, full fork lane): push-head-scope, branch-skew, file-size-check, desktop-tauri-checks, rust-tests (12/12 crate suites, 185s) — all passed.

Gemini 3.8 Flash tester

SKIPPED. Worktree google-calendar-test was created and just desktop-install completed. A tester run was launched against a written brief but was still running (no output) when this round had to conclude, so no verdict could be parsed. The worktree was left in place, not removed, so the run can be checked or resumed. git status --porcelain in that worktree showed no changes at the time of this report. Report path: see Follow-ups.

Blind critic

Findings (branch-wide, not all in this PR's diff — see Follow-ups for slice-2 scope):

  • T11 decision 4 has no code and no test on the branch: no (pubkey, normalized relay URL, channel id) mapping, no opaque mapping id, no call-time tuple revalidation. insert_event/patch_event/delete_event (client.rs:472,509,531) take a bare calendar_id: &str.
  • T11 decision 6's cache half is absent: no SQLite render cache, no partition/global/row bounds, no oldest-first eviction, no WAL checkpoint, no membership epoch.
  • T11 decision 7 (refresh on focus, bounded interval, backoff) has no implementation and no test.
  • T12a decisions 3, 4, 6, 7, 8, 9 and 10 have no code: no per-day segments/lanes, no Intl display zone, no CalendarMonth/MonthGrid, no virtualized agenda, no end-of-window state, no roving-focus keyboard path, no labelDayButton/Footer wiring.
  • The assigned e2e exercise could not run: no calendar view, no e2e mock bridge, no calendar spec exist on the branch.
  • T12a decision 11's truncated-field rule has no removal test on the path it governs: patch_event never calls CalendarEvent::can_edit_field.
  • fetch_events discards an already-proven batch on a malformed page (client.rs:388) instead of preserving the proven interval like every other stop path.
  • No traceability table maps every T11 decision to a named test.

What holds: DTO caps, the proven-interval algebra, the transport containments, and the compare-and-set fences are faithful to the memos, each with a removal-fails test. This branch is slice one only (Rust contract + data layer, no UI/cache/commands) by the ticket's own declared split (mod.rs:8-14); 9 of 22 numbered decisions have no implementing code, three of those inside this slice's own boundary. All gates and evals pass; both fork deviations are visible in the diff and justified.

GPT-5.6 Sol audit

Three review rounds (effort xhigh), converging OPEN with the following state at completion:

Resolved across rounds (verified fixed, confirmed by removal-fails tests):

  • The .no_proxy() bearer-token containment now has a differential test with origin pinning (base_url private, parsed, matrix-checked).
  • The compare-and-set seam (SecretStore::mutate_checked) is now exercised through the shipped KeychainEnvelopes::commit, not a reimplementation.
  • The loopback listener's total-wait guard now bounds the whole connection, not just each read, with a TimedOut test.
  • The page-cap overshoot half of the event-cap ordering defect is fixed (page truncated to remaining room before extend, with a test).

Still open at completion (all WARN, none reachable on this branch today — see Follow-ups):

  • reset.rs deletes the whole keychain blob on boot reset, including a google_calendar:<pubkey> envelope — not reachable since nothing on this branch persists an envelope yet.
  • fetch_events hardcodes refresh_already_forced = false at both call sites, so Terminal(Unauthorized) is unreachable from the retry walk.
  • A refused mutate_blob_checked call leaves self.cache stale (the adjacent no-op branch refreshes it; the refusal branch doesn't).
  • The event-cap check still runs before the page_token.is_none() check, so an exact-fit final page with no continuation token is reported truncated although the window was complete.
  • insert_event/patch_event take a raw, unbounded serde_json::Value with no writable-field allowlist and no body-size cap (W5) — no caller exists yet.
  • error_reason() returns an uncapped string that is capped for matching but not for the detail field that reaches logs and command errors.
  • AbandonRevocation names a recovery no transition can perform: an abandoned/terminal journal entry can never be cleared, so JournalFull becomes permanent once eight accumulate.
  • wait_for_callback returns a bare String (not Redacted) carrying the authorization code, and its state check happens after acceptance rather than during it.
  • A new test helper (transport_with_proxy_env) mutates process-wide proxy env vars inside a 3408-test parallel binary — a probabilistic flake risk, not yet observed.
  • calendar_id/event_id/etag are unbounded &str at the client boundary, unlike every other identifier in the module — not reachable, since nothing calls these functions yet.

Full round-by-round detail (including discarded/downgraded findings and reasoning) is in the audit reports linked in Follow-ups.

Follow-ups

  • Record the T12 backend/UI split in the implementation plan's ticket list, or fold T11 decisions 4, 6 (cache) and 7 back into this slice — today the split is declared only in mod.rs:8-14.
  • IdTokenSignatureVerifier (oauth.rs:352-366) has no production implementation and no cached JWKS; slice two needs a JWKS-backed verifier.
  • W5 carried forward: insert_event/patch_event (client.rs:472, 509) take unbounded JSON with no writable-field allowlist and no body size cap — must land before any Tauri command is registered.
  • T11 decision 9's agent-denial test covers the credential lookup but not "production shell and file tools", and there is no calendar command yet to deny — extend when slice two registers commands.
  • EventsPage::max_start_lower_bound_ms (dto.rs) is dead code; fetch_events computes its own maximum inline — use it or drop it so the two computations cannot drift.
  • Sol WARN: reset.rs boot-reset deletes the whole keychain blob, which would erase a Calendar envelope's revocation journal — blocking on slice 2 (calendar disconnect must integrate with sign-out ahead of reset).
  • Sol WARN: fetch_events passes refresh_already_forced: false at both call sites, making Terminal(Unauthorized) unreachable from the retry walk.
  • Sol WARN: a refused mutate_blob_checked call leaves the in-process cache stale; the no-op branch refreshes it but the refusal branch doesn't.
  • Sol WARN: the event-cap check still runs before page_token.is_none(), so an exact-fit final page is reported truncated although complete.
  • Sol WARN: error_reason()'s uncapped string reaches detail (logs, command errors) even though it's capped for classification matching.
  • Sol WARN: AbandonRevocation leaves a terminal/abandoned journal entry un-clearable, so JournalFull becomes permanent after eight entries with no way out.
  • Sol WARN: wait_for_callback returns a bare String instead of Redacted, and its state check runs after acceptance instead of during it.
  • Sol WARN: transport_with_proxy_env mutates process-wide proxy env vars inside a parallel 3408-test binary — re-exec as a child process instead.
  • Sol WARN: calendar_id/event_id/etag are unbounded &str at the client boundary — introduce validated newtypes checked at the mapping ingress.
  • Critic follow-up: exercise the frontend/e2e slice (T12a decisions 3-11) once the calendar view lands — no view, mock bridge, or spec exist yet on this branch.
  • Critic follow-up: patch_event should take the event plus changed fields and reject a truncated field via CalendarEvent::can_edit_field, with a removal-fails test.
  • Critic follow-up: fetch_events should return stop!(TruncationReason::Transport, ...) on a malformed page when an earlier page already completed, instead of discarding the proven interval.
  • Tester note: the Gemini tester run did not complete in this round; worktree google-calendar-test and its report path were left in place for a follow-up run to check or resume.

Reports:

  • Critic: /private/tmp/claude-501/-Users-zero-suminc-/a0fab389-7ac7-4f57-88e6-599d452e8e3a/scratchpad/wave4/google-calendar/critic.md
  • Sol audit rounds: /private/tmp/claude-501/-Users-zero-suminc-/a0fab389-7ac7-4f57-88e6-599d452e8e3a/scratchpad/wave4/google-calendar/audit.md, audit-delta-2.md, audit-delta-3.md
  • Tester: /private/tmp/claude-501/-Users-zero-suminc-/a0fab389-7ac7-4f57-88e6-599d452e8e3a/scratchpad/wave4/google-calendar/gemini-report.md

Test plan

  • cargo test google_calendar -- --list | grep -c ': test' >= ticket bar (5); 67 listed
  • cargo test google_calendar — 67 passed, 0 failed
  • just desktop-test — 6452 passed, 0 failed
  • cargo test --lib (desktop/src-tauri) — 3387 passed, 0 failed
  • just fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy file-size-check — all exit 0
  • No unwrap()/expect()/unsafe introduced in production paths
  • No new public API without doc comments
  • desktop/src-tauri/Cargo.lock unchanged (no dependency added)
  • DCO: every commit on the branch carries Signed-off-by and Co-Authored-By: Claude Fable 5.1
  • Merged origin/zs/main into the branch; no file outside CLI/docs was touched, no conflicts, no rerun of fast gates required
  • Pushed; full pre-push gate suite (fmt, clippy, desktop/Tauri checks, file-size, all 12 Rust crate test suites) passed
  • Frontend/e2e exercise of T12a's calendar view — not applicable to this slice (no view exists yet; tracked as a follow-up)
  • Gemini tester pass — not completed this round (see Follow-ups); worktree left in place for resume

🤖 Generated with Claude Code

https://claude.ai/code/session_01E51uwemNnQ6wdrBWU9EhPE


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

wiggdevin and others added 6 commits September 5, 2026 11:35
`mutate_blob` runs a read-modify-write under the interprocess advisory lock
with a fresh read inside it, but its mutation cannot refuse. A caller that
needs "write this only if the stored state still says X" therefore had to
check and then write, which loses the race the check exists for.

Add `mutate_checked`, the same operation with a fallible mutation: an `Err`
leaves the durable blob and the cache exactly as they were and is returned to
the caller, so a predicate can be evaluated on the freshly-read durable state
inside the lock. `mutate_blob` now delegates to it, so there is one
implementation of the locking, reading and copy-on-write rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
The credential half of T12, implementing the accepted memo
`docs/plans/2026-09-04-calendar-authorization.md`. Nothing here is wired to a
command, the webview or the sidebar, and no token reaches either: the module
is the contract, the views and the render cache are slice two.

* `redact` — the wrapper every credential is held in. It renders as a fixed
  marker and has no serializer, so a struct holding one cannot be serialized
  into a UI payload at all; persistence goes through an explicit wire form
  that is private to `binding`.
* `oauth` — PKCE S256, a CSPRNG `state` checked with a constant-time compare,
  a CSPRNG `nonce` checked inside the ID token, the bounded callback parser,
  and the exchange conditions (refresh token, ID token, every requested
  scope) short of which no binding is written. Signature verification is a
  trait with no implementation here, so no path can produce validated claims
  without one being supplied (decisions 1 and 2).
* `loopback` — the callback listener, bounded on request bytes, connections
  accepted, per-read time and total wait (decision 1).
* `binding` — the stored envelope and five transition-specific
  compare-and-set predicates, each evaluated on the freshly-read durable
  state inside the store's lock. Disconnect clears the binding and opens the
  journal entry in one commit, so no prefix of it leaves a grant unreachable
  and unrevoked (decisions 2, 5 and 6). The envelope key sits outside the
  `mcp:` namespace the launcher resolves, which is decision 9's denial seam.
* `revocation` — the journal: only HTTP 200 confirms, an entry clears only
  when the purge and the revocation both confirm, failures back off, and the
  seven-day ceiling converges on the terminal `revocation_unconfirmed` state
  rather than retrying forever (decision 5).
* `failure` — the three error matrices and their four states, each with an
  explicit default that fails closed (decision 8).
* `interval` — what a batch proves, half-open and never inferred (T12a
  decision 13).

Every predicate has a test that fails when the predicate is deleted, and the
concurrent disconnect is a barrier-held race over the shipped transition code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
The data half of T12 slice one, implementing
`docs/plans/2026-09-04-calendar-view-design.md` where it decides the model and
the wire behaviour. Still nothing wired to a command or the webview.

* `dto` — the only way API-sourced text enters the fork. Every string is
  capped here, at the boundary, with one `truncated` flag per field because a
  truncated field is read-only; the caps also hold a row inside T11 decision
  6's 256 KiB bound. An all-day value stays a date in the calendar's zone,
  never an instant. Editability is `accessRole` narrowed by event type and
  organizer, and an unrecognized role or type is read-only (decisions 1 and 2).
* `client` — the bounded `events.list` walk. Pages, bytes read off the socket,
  events accumulated and wall-clock time are each capped, a page cut at the
  byte budget is discarded whole, and the batch carries the interval it proved
  plus the classified failure that stopped it. The three mutations fence with
  `If-Match`, carry a client-generated id so a lost create is replayable, and
  treat a 404 as ambiguous until the calendar is probed (decisions 5, 11, 13).
* The shipped transport pins HTTPS, refuses redirects — a followed redirect
  would replay the bearer token at another host — and never reads proxy
  settings from the process environment, which a managed agent at operator
  trust can write.
* `mock_server` — a keep-alive mock Google Calendar with two principals on one
  shared calendar, an ACL-loss switch, paging, `If-Match`, a duplicate-id
  create and a redirect probe, so the transport, the walk, the caps, the
  classification and the parser under test are all the shipped ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
One consolidated fix round on T12 slice one. Every guard the review found
undertested now has a test that fails when the guard is deleted, and two
guards that bounded the wrong thing were corrected.

Transport containment (client.rs). `TransportConfig::base_url` is private
and the origin is pinned by parsing, not prefix-matching: a shipped
configuration reaches `https://www.googleapis.com/calendar/v3/` and nothing
else, so `www.googleapis.com.evil.test`, a non-default port, another API
path, an embedded credential and a query on the base are all refused. The
`no_proxy()` containment now has a differential test: with every proxy
variable pointing at a sentinel listener, a test-only configuration that
reads the environment routes there, and the shipped one reaches the
loopback mock directly with the sentinel untouched. Deleting `no_proxy()`
fails it.

Loopback listener (loopback.rs). The wait's deadline is carried into
`read_callback`, so each read waits for the shorter of the idle bound and
the wait's remainder. Before, the only bound inside a connection was the
per-read timeout, which resets on every byte: one local process could hold
the flow for hours without ever sending CRLFCRLF. `ListenerError::TimedOut`
now has two tests, one with no connection and one with a stalled one;
without the fix the second runs 30 s against a 250 ms budget.

Compare-and-set seam (secret_store.rs, binding.rs). The candidate builder
inside `mutate_checked` is extracted as `checked_candidate` and tested
without a keychain, so turning `f(&mut next)?` into a discarded result fails
a test on every platform. `KeychainEnvelopes` commits through a
`CheckedBlob` seam, and every envelope test now drives that shipped commit
over an in-memory blob rather than a reimplementation: swapping the
`Refused` and `Store` arms fails seven tests. A backend failure and an
unreadable stored envelope each have their own test.

Bounds that bounded the wrong quantity. The walk applies the event cap to a
page before adding it, so it can no longer return `max_events + page - 1`
events, and the proven interval ends at the last kept start rather than the
page's. An over-long `recurringEventId` is dropped rather than truncated
into a different id.

Also bound: the `events.list` query Google is asked for (`singleEvents`,
`orderBy=startTime`, `timeMin`, `timeMax`, `maxResults`), the id, etag,
zone and page-token caps, the id-token byte cap, and T11 decision 9 driven
through the shipped `McpSecretLookup::resolve` with a working control.

The mock server no longer `dup`s its socket: a failing `try_clone` near the
descriptor limit closed a connection silently, which a client sees as a
reset mid-request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
The events-URL tripwire in `egress_guard_tests.rs` scans every `.rs` file
under `desktop/src-tauri/src` for `/events` URL construction and fails until
each site is paired with an inventory row. The Google Calendar slice adds
five such sites — `client.rs:606` (`events_path`), `mock_server.rs:287` and
three fixtures in `client_tests.rs` — so `cargo test --lib` (the lane behind
`just desktop-tauri-test` and CI's Tauri Rust job) failed
`events_url_inventory_is_fully_guarded`.

Answer the question the tripwire asks rather than silence it. The Calendar
REST collection is not a relay egress boundary: the request goes to
`www.googleapis.com` with a Google bearer token and carries no Nostr event,
so `assert_no_key_backup` — whose stated scope in `egress_guard.rs` is
relay-bound egress of NIP-49 backup material — has nothing to check there.
Record that as three rows with zero expected guard calls and the reasoning
written beside them, in the file's existing style.

The rows still fence the files: a second `/events` site in `client.rs`, or a
fourth in `client_tests.rs`, trips the scan and asks again. The existing
mutation tests (`inventory_scan_catches_new_site_in_allowlisted_file`,
`inventory_scan_catches_removed_guard_call`,
`inventory_scan_catches_new_unlisted_file`) already prove the pairing fails
when a row or a guard call is removed, so no new mutation test is added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com>
@wiggdevin
wiggdevin marked this pull request as ready for review September 5, 2026 20:28
@wiggdevin
wiggdevin enabled auto-merge September 5, 2026 20:28
@wiggdevin
wiggdevin added this pull request to the merge queue Sep 5, 2026
Merged via the queue into zs/main with commit 0dcb7fe Sep 5, 2026
75 of 76 checks passed
@wiggdevin
wiggdevin deleted the feat/google-calendar branch September 5, 2026 21:30
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