Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8c0198b
google calendar integration bug fixes
XiovV Sep 10, 2026
5cf5766
Exposure: a Calendar's CalDAV home-set membership is per-User (ADR-0080)
XiovV Sep 10, 2026
f94ce08
Make a Linked Calendar's Refresh-owned Event columns unwritable by a …
XiovV Sep 10, 2026
cb4ac34
Writable CalDAV on a Linked Calendar: a PUT compiles to Write-back pu…
XiovV Sep 10, 2026
08f4190
Calendar Sets exist: create, rename and delete them in Settings
XiovV Sep 11, 2026
0aa2a43
Putting Calendars into a Calendar Set: membership with immediate effect
XiovV Sep 11, 2026
e8c4ec6
Selecting a Calendar Set narrows the sidebar and the grid
XiovV Sep 11, 2026
b56963a
The Calendar Set switcher: zero-state, deep link and reset semantics
XiovV Sep 11, 2026
5fcc74e
The Event modal's Calendar picker narrows to the Active Calendar Set
XiovV Sep 11, 2026
8bf94ac
An empty Calendar Set explains itself in the sidebar
XiovV Sep 11, 2026
f7d3ce4
Add a Calendar to a Set from its sidebar row menu
XiovV Sep 11, 2026
57b6a4e
Creation dialogs offer to add the new Calendar to the Active Calendar…
XiovV Sep 11, 2026
fd606fd
Extract the add-to-Set checkbox shared by the three creation dialogs
XiovV Sep 11, 2026
1965946
Tasks are their own VTODO-shaped entity, on two independent date axes
XiovV Sep 11, 2026
3461f63
Task Lists exist, with Inbox
XiovV Sep 11, 2026
afb707a
Capture and tick off a Task (#310)
XiovV Sep 11, 2026
7630796
Fix Task List delete race and quick-add draft loss (#310)
XiovV Sep 11, 2026
b257136
Deadlines, the detail surface, and Task buckets
XiovV Sep 11, 2026
1a00ad0
Deadline-only Tasks in the all-day lane
XiovV Sep 11, 2026
be91350
Time blocks on the grid, dragged from the panel
XiovV Sep 11, 2026
c063cd6
The rest of the drop table
XiovV Sep 11, 2026
2f8c6d6
Tasks in Month view
XiovV Sep 11, 2026
c1ce552
Group by Task List and Priority
XiovV Sep 11, 2026
d48ff8e
Busy on an Event
XiovV Sep 27, 2026
8e91546
fix styling of the task block
XiovV Sep 27, 2026
e8d63e4
Add Availability Schedules (#320)
XiovV Sep 27, 2026
e31185f
Add Handle: a User's public name (#321)
XiovV Sep 27, 2026
d6fecfe
Add Booking Link CRUD and the sidebar section (#322)
XiovV Sep 27, 2026
dd36e42
Add slot derivation and the slots endpoint (#323)
XiovV Sep 27, 2026
b8d42b4
Add the public Booking Link page (#324)
XiovV Sep 27, 2026
5cfbf03
Add the public index page (#325)
XiovV Sep 27, 2026
a2b024b
Add booking a slot: the public write path for Booking Links
XiovV Sep 27, 2026
97f3c8e
Add cancelling a booking
XiovV Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 99 additions & 7 deletions CONTEXT.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# A Linked Calendar is not exposed over CalDAV at all

Status: accepted — supersedes ADR-0054
Status: superseded by ADR-0080

A Linked Calendar appears in **no** principal's CalDAV home-set: not its Owner's, not that of a Workspace Member it was Shared to. `ListCalendars` filters out every Calendar whose Source is a Connection, unconditionally.

Expand Down
53 changes: 53 additions & 0 deletions docs/adr/0079-a-write-back-that-does-not-reach-google-says-so.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# A Write-back that does not reach Google says so, quickly and specifically

Status: accepted — amends ADR-0075 (Write-back's failure surface) and ADR-0060 (the outbox's statuses)

Three rules, one posture. A push that Google rejects **fails on the first response** rather than walking the backoff schedule; a push that was **never dispatched** is recorded as `skipped`, not `sent`; and a failed call **keeps Google's own explanation** instead of discarding it.

ADR-0075 built Write-back's failure surface on the premise that a push either lands or is visibly refused: *"a pending push needs no UI; it lands in seconds"*, with a per-Event marker and the Connection's needs-attention state for the permanent case. Every part of that was implemented. None of it fired, because all three rules above were the other way round, and each one independently hid the same fault.

## What the absence of these rules cost

Every Write-back this app had ever sent failed with **400**. The patch body carried an empty `date` beside a populated `dateTime` — a pair Google treats as mutually exclusive — because the encode path shared the decode struct, which has no `omitempty`. That is an ordinary bug, fixed in one struct, and it is not what this ADR is about.

What this ADR is about is that the bug survived in production while three separate mechanisms designed to surface it stayed silent:

- **The 400 was retried as though it were an outage.** Only 412 and a dead grant were classified; everything else fell through to 10s / 30s / 2m / 10m. A request that is guaranteed to be rejected identically five times consumed thirteen minutes before its Event could show a marker — and a server restart mid-schedule left the row `pending` forever, where the marker never arrived at all.
- **A push that was never dispatched was recorded as `sent`.** The "nothing left to push" paths returned a bare `nil`, which the Worker cannot distinguish from delivery. The outbox therefore contained rows claiming Google had accepted an edit it had never been shown — beside a stale `last_error` from an earlier attempt, which read as a contradiction and was in fact two different truths about two different attempts.
- **Google's explanation was thrown away.** Every call drained its failed response into `io.Discard` beneath a comment asserting *"the response carries nothing we need on failure"*. For a 4xx that is precisely backwards: the body names the offending field. `last_error` said `status 400` and nothing more, so the one artefact that should have ended the investigation instead started it.

None of these is severe alone. Together they made a total, permanent, every-single-write failure look exactly like nothing happening.

## The four statuses

| status | meaning |
| --- | --- |
| `pending` | queued, or waiting out a backoff |
| `sent` | dispatched, and Google accepted it |
| `skipped` | terminal; never dispatched, with the reason in `last_error` |
| `failed` | dispatched, and permanently rejected |

`skipped` is terminal like `sent` — the Worker does not retry it, because the state that produced it does not resolve on its own — and, unlike `failed`, raises no per-Event marker of its own. Most skips are races the User caused: they deleted the Event, they disconnected the account, they let a Refresh flip the Calendar read-only. The two that do leave the User's intent unfulfilled (an undeliverable DELETE or CANCEL_INSTANCE) already raise the Source's needs-attention state before skipping, exactly as ADR-0077 required; the status change does not weaken that, it stops the row from also claiming delivery.

`sent_at` stays NULL on a skip. A timestamp there would reintroduce the ambiguity the status exists to remove.

**The alternative was to keep `sent` and write the reason into `last_error`.** It needed no migration and would have fixed the misleading *row*. It was declined because it does not fix the misleading *query*: "did my edits reach Google?" is answered by `status`, and every tool, every future reader, and every one of these investigations starts there. ADR-0048 makes the schema cost near zero while this project is pre-release, which is the moment to spend it.

## Failing fast on 4xx

400, 401, 403 and 404 are marked permanent on the first response, via an `ErrPermanent` sentinel the Sender wraps and the Worker checks before its retry arithmetic. Three statuses stay on the retry path because they describe a moment rather than the request: **408**, **429** (where waiting is exactly the remedy), and **412**, which SendWriteBack's own bounded refetch loop owns and which must not be reclassified underneath it. 5xx and transport errors are untouched — those are the outage backoff was built for.

**The cost is stated plainly, because it is how this trade goes wrong.** A 403 raised by an ACL change that is reverted a minute later now fails permanently where it might once have retried into a success. Editing the Event re-enqueues the push (ADR-0077), which is the recovery. That is a better default than every genuinely hopeless push holding its Event's marker back for thirteen minutes, because the hopeless case is the common one and the transient-403 case is the rare one.

## Keeping the Provider's reason

A failed response's body is read to a bounded prefix (512 bytes), collapsed to a single line, and carried on the error — at **every** Google call site, not only the one that bit us. The bound is not about Google, whose error JSON is small; it is about a proxy or captive portal answering with an HTML page that would otherwise flood `last_error` and a grid tooltip.

Logging the body instead of storing it was the alternative, and was declined for the reason this whole ADR exists: the outbox row is where someone actually looks, and a row that cannot explain itself sends them to the code.

## Consequences

- **The bug class this closes is "a mechanism that reports nothing is indistinguishable from a system with nothing to report."** All three rules are instances of it. It is worth naming, because the next integration will have its own.
- **Tests must assert what is sent, not only what is not sent.** ADR-0075's suite is a thorough set of *negative* assertions — no attendees, no `conferenceData`, no visibility, no unexpected top-level key — and it was airtight. It never descended into `start`/`end`, so a body that was well-formed at the Go type level and invalid on the wire passed everything. Golden marshalled bodies, one per encoding shape, now pin the bytes; the negative assertions remain, because they guard a different thing.
- **A type serving both encode and decode will eventually satisfy neither.** The decode side wants every field present and bare; the encode side must send exactly one of a mutually-exclusive pair. That disagreement is not visible in the struct, only on the wire. The two are now separate types, and the encode one enforces its invariant in `MarshalJSON` so no call site can opt out — the same standard ADR-0075 already set for the field-scoped patch.
- **Historical rows are not rewritten.** A past `sent` that was really a skip stays `sent`; inventing a reason for it would be worse than leaving it. The distinction begins here.
41 changes: 41 additions & 0 deletions docs/adr/0080-exposure-caldav-home-set-membership-is-per-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Exposure: a Calendar's CalDAV home-set membership becomes a per-User choice

Status: accepted — supersedes ADR-0074, reintroducing ADR-0054's viewer-dependent design

Whether a Calendar appears in a User's own CalDAV home-set is now that User's own choice, resolved per `(calendar_id, user_id)` from `calendar_exposures` — a table reusing `calendar_user_colors`' shape verbatim (ADR-0038): no indirection, cascading on both Calendar and User. Absent a row, the default is exposed, except a Linked Calendar's own Owner, who defaults to unexposed. The home-set filter and the direct-path lookup both consult it for **every** Calendar, not only Connection-sourced ones, even though only a Linked Calendar gets a UI toggle today (Not in scope, below). Read-only when this landed — a device's own write surviving Exposure is ADR-0081 (#299).

## Why the cost ADR-0074 refused to pay is already paid

ADR-0074 rejected ADR-0054's viewer-dependent home-set for one named reason: it would make `ListCalendars` "a function of the requesting principal, not just of the Calendar set," and landing that in the same release as write-back, conflict handling, and the series mapping risked a quiet failure mode — a home-set correct for the Owner and wrong for an accessor, passing every test written from the Owner's seat alone. ADR-0054 had named the same cost itself and bet that #163's Invitations collection would pay it first.

That bet paid off before this ADR needed it to. #163 shipped a principal-dependent `ListCalendars` for its own, unrelated reason: `caldavserver/calendars.go`'s `ListCalendars` already runs `ListAccessible` and `ListAttendeeOnlySeries` side by side and appends a synthetic Invitations collection only for a principal who actually holds an Attendee-only Event. The shape ADR-0074 was trying to avoid being first to introduce has already shipped, under a different name, for a different reason — reintroducing it for Exposure adds a second branch to code that already has one, not a new kind of risk to the codebase.

This is why this reads as a return to ADR-0054 rather than a reversal of ADR-0074's taste: the thing ADR-0074 refused to be first to risk is a bill this codebase already paid.

## What's actually different from ADR-0054's design

ADR-0054 specified a Calendar-scoped toggle, overridable "per Linked Calendar" — a column or sibling row keyed on the Calendar alone would have made sense for a single-User instance, but conflates two different people's answers to two different questions: the Owner's own preference and a Share-holder's own preference are not the same fact, and a Workspace can hold several of the latter for one Calendar. Exposure is keyed `(calendar_id, user_id)` instead, one independent answer per person who can see the Calendar at all — the shape `calendar_user_colors` already established for exactly this reason (ADR-0038).

## Decision

- `calendar_exposures(calendar_id, user_id, exposed)`, keyed and cascaded exactly like `calendar_user_colors`. Lands as a new append-only migration (`00007_calendar_exposure.sql`) rather than hand-edited into `00001_init.sql`, matching how `00002`–`00006` already added onto the last squash instead of folding into it — ADR-0048's periodic squash is a deliberate, occasional act, not something every schema change since the last one performs. Editing `00001_init.sql` directly would silently strand any database that already applied it through version 6: goose tracks applied versions by number, not content, so it would never re-run a changed version 1.
- Absent a row, the default resolves as: ordinary Calendar → exposed; Subscribed Calendar → exposed (ADR-0032 unchanged); Linked Calendar, requesting principal is Owner → not exposed; Linked Calendar, requesting principal holds a Share → exposed.
- The home-set filter (`ListCalendars`) and every entry point that resolves a Calendar straight off a request path — `GetCalendar` (PROPFIND), calendar-query, calendar-multiget, sync-collection, and PROPPATCH — resolve Exposure for every Calendar the caller can otherwise reach, mirroring the shape #163 established for the Invitations collection: not exposed reads as absent from the listing and 404 on a guessed, bookmarked, or already-synced collection URL, identically. A device that discovered and cached the collection before Exposure was turned off must lose it there too, not just on its next PROPFIND.
- **Access resolves first, Exposure second.** A Calendar the caller has no Access to is never in the accessible set Exposure is even asked about, so a revoked Share drops it regardless of what any leftover Exposure row says. `RevokeShare` and `LeaveShare` clear the caller's own Exposure row alongside their colour override, for the same reason ADR-0038 gives: a choice with no Access behind it would otherwise linger, invisible, until Access was ever granted again.
- The setting is read/write on its own endpoint (`PUT /api/calendars/{id}/exposure`), open to any User with Access — Owner and accessor alike — writing only the caller's own row. This is *not* folded into the general Calendar-update endpoint the way ADR-0038's colour override is: colour has an Owner's-own-value to fall back on when the caller isn't the Owner, and Exposure has none — every principal, Owner included, only ever writes their own row, so there is no owner-write/accessor-override branch to reuse.
- The UI toggle lives in a Linked Calendar's existing sidebar row menu, the one surface both the Owner and an accessor already reach — not Settings → Connections, where an accessor has no Connection to find it under and the Owner's own seat would default to on, which is backwards. Its label speaks in terms of devices, never CalDAV.
- The first App Password a User creates nudges them toward Exposure if they own a Linked Calendar still on the unexposed default — the same moment and pattern ADR-0027 established for the reminder-delivery toggle: the point where a User first demonstrates they have a device to expose *to*.

## Considered Options

- **Reintroduce ADR-0054's viewer-dependent defaults, now that #163 already pays the shared cost (chosen).**
- **Keep ADR-0074's blanket hide.** Cheapest to leave alone, but leaves the accessor's phone permanently empty and gives the Owner who has dropped native Google sync no way back onto CalDAV at all.
- **A per-Connection bulk toggle instead of a per-User, per-Calendar row.** Coarser and cheaper to build, but the wrong shape: the Owner's own preference and an accessor's own preference are different people answering different questions about the same Calendar, and one switch on the Connection can't hold both independently.

## Consequences

- **The accessor gap ADR-0074 accepted is closed.** A Workspace Member Shared a Linked Calendar now sees it on their phone by default, with no toggle to find and no dependency on the Owner doing anything on their behalf.
- **The Owner who has moved off native Google sync gets their own Linked Calendar back on CalDAV**, discoverable at the moment (first App Password) where its absence would otherwise be a silent surprise.
- **`linked_calendar_hidden_test.go`'s accessor-seat assertions invert**: the Calendar that test used to prove was absent from a Shared Member's home-set is now proven present by default, while the Owner's own exclusion — the same test file's other half — stays true as a default rather than an unconditional rule.
- **Ordinary and Subscribed Calendars carry Exposure too, with no UI to set it.** Accepted rather than special-cased away: the resolution logic asks the same question of every Calendar, so a future ADR can turn on a toggle for them without touching this one again.
- **The read-only CalDAV privilege set for a Linked Calendar, dead code since ADR-0074, is live again** for exactly the principals Exposure lets reach a collection at all — asserted directly, per #297's acceptance criteria, since it has never actually run against a Linked Calendar before now.
Loading
Loading