# Structure Cal API v1

Public guide: https://cal.structure.info/docs
Portable skill: https://cal.structure.info/skill.md
API base: `https://cal.structure.info/api/v1`

This API manages full selected Google calendars and independent native calendars.
Google reads include events created outside Structure Cal, with no booking-only
filter. Native events are stored by Structure Cal and are not mirrored to Google.

## Authenticate and get access

Send `Authorization: Bearer <gfavip_session_token>` on every API request. Wallet
validates the token on every request. All accounts map to stable GFAVIP user IDs
and have identical role rules; account kind and account ownership grant no access.

Use your existing GFAVIP headless sign-in integration to obtain a session token.
For PowerLobster clients: obtain an identity token using your configured
PowerLobster integration, then exchange it at
`POST https://wallet.gfavip.com/api/auth/powerlobster`, with JSON
`{"token":"<powerlobster_identity_token>"}`. The successful response contains
`sso_token`, `expires_at`, and `user.username`. Use `sso_token` as the calendar
bearer token. An API key, identity token, or Google token is not a substitute.
Reauthenticate when expired/revoked; no calendar-specific token refresh endpoint
exists. Keep tokens in a secret store, never in URLs or shared messages.

1. Call `GET /calendars` once to register your GFAVIP identity in Structure Cal.
2. Give the calendar owner your exact GFAVIP username. An empty calendar list is
   a successful login with no accessible calendars yet.
3. The owner signs in at https://cal.structure.info/calendars, selects **Add
   calendar → Find my Google calendars** (connecting Google if needed), and adds
   the desired calendar. Alternatively, the owner creates a native calendar.
4. The owner opens **Sharing** and grants your username or an existing team access.
5. Call `GET /calendars` again and use the returned local calendar `id`.

Each Google calendar must be selected and shared explicitly. Sharing exposes all
details visible to the connected Google account, including personal/private events.
Google credentials remain on the server. The teammate does not need Google OAuth.
The owner's Google permissions remain an upper bound on permitted operations.

## Roles

| Role | Access |
|---|---|
| Viewer | Read events and busy intervals |
| Editor | Viewer access plus create/edit/delete events |
| Admin | Editor access plus inspect audit/sharing and manage Viewer/Editor user grants |
| Owner | Full access; only the owner can grant Admin or manage team grants |

Only existing local usernames can be granted access; the recipient must first
sign in or authenticate through the API. After a GFAVIP username change,
reauthenticate to refresh the local username. Existing grants stay bound to user
IDs. Ambiguous usernames fail closed.

Team membership alone does not expose a calendar: its owner must add a team grant.
That grant includes the team's owner and current/future members. Membership is
checked on each request. Removing a grant does not remove other direct/team grants;
the strongest remaining role applies.

## Requests and responses

Paths below are relative to `/api/v1`. Send JSON with `Content-Type:
application/json` on writes. Errors normally have the shape `{"error":"message"}`.
Bearer clients do not use browser cookies or CSRF tokens. Browser clients instead
use their signed login session and the page-provided `X-CSRF-Token` on writes.

| Method | Path | Payload / query | Response |
|---|---|---|---|
| GET/PATCH | `/calendars/{id}/booking-policy` | Editor+ read/preview; Owner or delegated Editor+ apply | Rules, working hours, global `policy_etag` |
| GET/POST | `/calendars/{id}/trips` | Editor+ read; reviewed create, global `If-Match` | Chronological trips / preview or created trip |
| PATCH/DELETE | `/calendars/{id}/trips/{trip_id}` | Reviewed write, global `If-Match`; PATCH complete trip fields | Preview or JSON receipt (200) |
| GET | `/calendars/{id}/event-types` | Editor+ | Owned types and booking rules |
| GET/PATCH | `/calendars/{id}/event-types/{event_type_id}/booking-rules` | Editor+ read; reviewed write, global `If-Match` | Format and preferred/request-only hours |
| GET | `/calendars/{id}/booking-policy/history` | Owner; optional `before_id` | Audited policy changes and guarded undo eligibility |
| POST | `/calendars/{id}/booking-policy/history/{audit_id}/undo` | Owner; reviewed preview/apply, global `If-Match` | Preview or restored policy receipt |
| GET | `/calendars/{id}/free-slots` | Editor+; `duration`, `from`, `to`, optional `tz`, `guest_tz`, `prefer`, `event_type_id`, `limit` | Ranked suggestions, completeness and limits |
| GET | `/calendars/{id}/booking-reschedule-requests` | Editor+; `status`, `page_token` | Requests, `next_page_token`, `complete` |
| GET | `/calendars/{id}/bookings/{booking_id}/reschedule-requests/{request_id}` | Editor+ | Request + ETag |
| POST | `/calendars/{id}/bookings/{booking_id}/reschedule-requests/{request_id}/approve` | Preview/apply; `If-Match`; explicit `notify_guest`; apply hash + idempotency key | Reviewed move / durable result |
| POST | `/calendars/{id}/bookings/{booking_id}/reschedule-requests/{request_id}/decline` | Owner/delegated Editor+, `If-Match`, reason | Guest-visible declined request |
| POST | `/calendars/{id}/bookings/{booking_id}/reschedule-requests/{request_id}/reconcile` | Owner, `If-Match`, `confirm:true` | Proven completion or safely reopened proposal |
| GET/PUT | `/calendars/{id}/bookings/{booking_id}/prep` | Editor+; PUT `If-Match`, notes and links | Private prep + ETag |
| GET | `/calendar-notifications/subscriptions/{subscription_id}/deliveries` | Current subscription member, optional `after` | Latest delivery states, `next_cursor`, `has_more` |
| GET | `/calendars` | None | `{"calendars":[{"id":1,"name":"Personal","provider":"google","role":"editor","connected":true}]}` |
| POST | `/calendars` | `{"provider":"native","name":"Personal"}` or `{"provider":"google","provider_id":"<discovered Google calendar id>"}` | `{"calendar":{...}}`, 201; existing Google registration: 200 |
| GET | `/google/calendars` | Optional `page_token`; caller must have a Google connection | `{"calendars":[{"id":"...","summary":"...","accessRole":"owner","primary":true}],"next_page_token":null}` |
| GET | `/calendar-teams` | None | `{"teams":[{"id":1,"name":"Team"}]}` |
| GET | `/calendars/{id}/grants` | Admin/Owner | `{"grants":[{"id":1,"role":"editor","username":"teammate","team_id":null,"team_name":null}]}` |
| POST | `/calendars/{id}/grants` | `{"username":"teammate","role":"editor"}` OR `{"team_id":1,"role":"viewer"}` | `{"grant":{...}}`; creates or updates access |
| DELETE | `/calendars/{id}/grants/{grant_id}` | No body; Admin restrictions apply | 204, empty body |
| GET | `/calendars/{id}/events` | Required `time_min`, `time_max`; optional `page_token` | `{"events":[...],"next_page_token":null,"complete":true}` on the final page |
| POST | `/calendars/{id}/events` | Event envelope below; `Idempotency-Key` required | `{"event":{...}}`, 201; successful replay: 200 |
| GET | `/calendars/{id}/events/{event_id}` | None | `{"event":{...}}` |
| PATCH | `/calendars/{id}/events/{event_id}` | Changed fields in event envelope; `If-Match` required | `{"event":{...}}` with new `etag` |
| DELETE | `/calendars/{id}/events/{event_id}` | `{"send_updates":"none"}` or `{"send_updates":"all"}`; `If-Match` required | 204, empty body |
| GET | `/calendars/{id}/busy` | Required `time_min`, `time_max` | `{"busy":[{"start":"...","end":"..."}]}` |
| GET | `/calendars/{id}/bookings` | Editor+; `time_min`, `time_max`; optional `status`, `page_token` | `{"bookings":[...],"next_page_token":null,"complete":true,"allow_editor_booking_reschedule":false,"allow_editor_booking_override":false}` on final page |
| GET | `/calendars/{id}/bookings/{booking_id}` | Editor+ on the host's primary Google calendar | `{"booking":{...}}`, including `booking_version` |
| POST | `/calendars/{id}/bookings/{booking_id}/reschedule` | Reviewed bounds, explicit `notify_guest`; preview/apply contract below | Read-only preview or confirmed booking/event plus durable `operation` |
| GET | `/calendars/{id}/audit` | Admin/Owner | `{"changes":[{"actor_user_id":1,"event_id":"...","action":"event.update","at":"...Z"}]}`; newest 100 |

Google discovery is for the caller's own connection. Shared-calendar members use
`/calendars` to discover granted calendars; they do not discover the owner's whole
Google account. The API uses local integer calendar IDs, not raw Google IDs, in
calendar/event paths. URL-encode provider event IDs as path components.

## Read events

Load `GFAVIP_SESSION_TOKEN` securely into your environment. Set `CALENDAR_ID` to a
local ID returned by `/calendars`. These examples use fictional event data.

```sh
curl --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $GFAVIP_SESSION_TOKEN" \
  https://cal.structure.info/api/v1/calendars

curl --fail-with-body --silent --show-error --get \
  -H "Authorization: Bearer $GFAVIP_SESSION_TOKEN" \
  --data-urlencode 'time_min=2026-10-01T00:00:00+07:00' \
  --data-urlencode 'time_max=2026-11-01T00:00:00+07:00' \
  "https://cal.structure.info/api/v1/calendars/$CALENDAR_ID/events"
```

Both date-range bounds must have UTC offsets (or `Z`), with a positive span of at
most 366 days. Choose dates for the user's requested period. When
`next_page_token` is non-null, repeat the same request with that value as
`page_token` until it is null. Every successful page includes `next_page_token`
(explicit JSON null on the final page) and `complete` (true only when no further
page remains). Accumulate **all** pages before claiming the range is complete;
the final page alone is not the entire range. An empty page with a token is not
finished. Pages contain up to 100 events. Native pagination
uses offsets, so concurrent edits can change page boundaries; these are not
snapshot reads. Do not assume an empty/error response from a failed provider
request means the user is free.

Event responses can include `id`, `etag`, `summary`, `description`, `location`,
`start`, `end`, `status`, `transparency`, `reminders`, `attendees`, `organizer`, `recurrence`,
`recurringEventId`, `originalStartTime`, `htmlLink`, and `hangoutLink`. Fields may
be absent; in particular an existing event may have no title. Google recurring
series are expanded into occurrences in list responses.

## Create an event

Send `POST /calendars/{id}/events` with a unique `Idempotency-Key` header of 8–200
characters. A UUID is suitable. Keep it for retries of this intended creation.

```json
{
  "event": {
    "summary": "Project review",
    "description": "Review next steps",
    "location": "Office",
    "start": {"dateTime":"2026-10-12T14:00:00+07:00","timeZone":"Asia/Bangkok"},
    "end": {"dateTime":"2026-10-12T15:00:00+07:00","timeZone":"Asia/Bangkok"}
  },
  "send_updates": "none"
}
```

Writable fields are `summary`, `description`, `location`, `start`, `end`, and
`reminders`, and `transparency` (`opaque` or `transparent`). Google events also accept explicit attendee authoring below.
Native and Google calendars also accept `recurrence`; see the provider-specific
recurrence sections below. Timed Google series require named IANA timezones on
both bounds, in addition to their UTC offsets.
Creation requires a nonblank title (maximum 500 characters), start and end.
Description/location are optional strings up to 10,000 characters each. Timed
bounds require offsets; an optional `timeZone` must be an IANA timezone name.
End must follow start. Clear description/location with an empty string, not null.

For an all-day event on October 12, use these bounds instead:

```json
{"start":{"date":"2026-10-12"},"end":{"date":"2026-10-13"}}
```

The all-day end date is exclusive. Do not mix `date` and `dateTime` bounds.
Native all-day availability uses the calendar owner's timezone.

### Optional duplicate check

Use `GET /calendars/{id}/duplicates?summary=School%20day&date=2030-01-05&tz=Asia/Bangkok`
to preview possible duplicates. It returns `matches`, `has_duplicates`, `date`,
`timezone`, and `advisory: true`. Matching normalizes Unicode, letter case and
whitespace, and compares the event's **start date** in the selected timezone.
It does not treat every overlapping multi-day event as a duplicate.

Alternatively, include `"duplicate_check": true` and optional
`"duplicate_timezone": "Asia/Bangkok"` alongside `event` and `send_updates` in a
creation request. The default check timezone is the calendar owner's timezone.
Possible matches return 409 with `code: "possible_duplicate"` and `matches`,
without creating the event. After reviewing, explicitly send `duplicate_check:
false` if the second event is intentional. Existing idempotent creations return
their event even if another possible duplicate has since appeared. This check is
advisory: simultaneous independent creates can race; idempotency keys remain
necessary. The browser create form enables the check by default.

Every event create/edit/delete requires an explicit `send_updates` choice:
`"all"` requests Google attendee notifications; `"none"` suppresses them. Base
the choice on the user's instructions; resolve uncertainty before the write.
Native event CRUD uses `"none"`; native invitation emails require a separate explicit
invitation action, and reminders require a member subscription.

## Move, edit or delete an event

Read the current event first. Take `event.etag` from the JSON response and send
its exact string, including quotes, as the `If-Match` header. Do not infer the
version from the event ID or assume it is an HTTP response header.

To move an occurrence, PATCH its returned event ID with only changed fields:

```json
{
  "event": {
    "start": {"dateTime":"2026-10-12T16:00:00+07:00","timeZone":"Asia/Bangkok"},
    "end": {"dateTime":"2026-10-12T17:00:00+07:00","timeZone":"Asia/Bangkok"}
  },
  "send_updates": "none"
}
```

Send both bounds when converting between timed and all-day events. Existing
untitled events can be edited without adding a title; omit unchanged fields.
To delete, send DELETE with the same version header and a JSON body containing
only the notification choice. A 204 response has no JSON body.

## Conflicts and retries

- Create keys are scoped to calendar and actor. Retry a create with the same
  key and payload, including notification choice. A changed payload with the same
  key returns 409. Deleted creations cannot be resurrected by reusing their key.
- A stale `If-Match` returns 412. Reread and reconcile with the user's requested
  change before submitting a fresh version; never blindly overwrite newer data.
- After a timeout or upstream error during update/delete, reread the event before
  retrying. The provider may have applied the write despite the failed response.
- Honor retry delays when available and bound retries for temporary upstream
  failures. Report unresolved uncertainty rather than claiming a write succeeded.

| Status | Meaning / next step |
|---|---|
| 400 | Invalid fields, range, page token or notification choice; correct the request |
| 401 | Missing/invalid/expired Wallet token; obtain a valid session |
| 403 | Insufficient app role, Google permissions/quota, or browser CSRF failure |
| 404 | Missing resource or no access; username may need first authentication |
| 409 | Read the message: duplicate/idempotency conflict, missing Google connection, booking-managed event, series edit, username/identity reconciliation, or concurrent sharing change |
| 412 | Stale event version; reread and reconcile |
| 413 | JSON write payload too large (normally 64 KiB; group batches allow 256 KiB) |
| 422 | Aggregate result exceeds its completeness limit; use paginated events or fewer calendars |
| 428 | Missing `If-Match`; fetch the current event |
| 502 / 503 | Google/Wallet/configuration unavailable; do not assume success or an empty calendar |

Unknown `/api/...` paths and method mismatches return JSON with a `docs_url`.
An invalid event-list, Google-discovery or booking-list `page_token` returns 400 with
`code: "invalid_page_token"`, not a provider outage.
Restart the list without the token, discard the incomplete accumulated result,
and keep the range unchanged while following the new tokens. Do not retry the
same rejected token. Actual provider/network failures still return 502/503.
Recognized calendar paths missing `/api/v1`, such as `/calendars/2/events`, return
404 JSON with a `suggested_path`; writes are never automatically redirected.

## Daily agenda and combined calendars

`GET /calendars/{id}/agenda?date=2030-01-05&tz=Asia/Bangkok` returns a compact
complete day list. Omit `date` for **today in the requested timezone**; omit `tz`
for the caller's configured timezone. Day boundaries handle 23/25-hour DST days.
All-day events sort first; timed events sort by actual instant.

```json
{
  "date": "2030-01-05",
  "timezone": "Asia/Bangkok",
  "complete": true,
  "events": [{
    "calendar_id": 2, "calendar_name": "Personal",
    "id": "example-event", "etag": "\"version\"", "title": "School day",
    "start": "2030-01-05T10:00:00+07:00", "end": "2030-01-05T11:00:00+07:00",
    "all_day": false, "attendee_count": 2, "attendee_count_complete": true,
    "my_rsvp": null, "calendar_rsvp": "accepted"
  }]
}
```

All-day `start`/`end` remain date-only strings with exclusive end. `my_rsvp` is
matched against the authenticated GFAVIP email, and is null when it cannot be
identified. `calendar_rsvp` is Google's `self` attendee for that calendar copy (the owner
invitation email on a native calendar);
it is **not** necessarily the caller's RSVP. An omitted/truncated provider
attendee list sets `attendee_count_complete: false`.

**When an agent reads the owner's schedule:** use `calendar_rsvp` for the
connected calendar's response (normally the owner's response on their personal
calendar). `my_rsvp` refers only to the authenticated agent's own attendee email
and will usually be null. For example, `my_rsvp: null, calendar_rsvp: "accepted"`
means the calendar's attendee accepted; it does not mean the agent accepted or
that the owner has not replied. Neither field being null proves a decline. On a
calendar shared through Google, `calendar_rsvp` still describes that calendar
copy, so do not attribute it to a different owner or attendee without checking.

For multiple calendars, explicitly provide 1–20 comma-separated local IDs:

- `GET /agenda?calendar_ids=1,2&date=2030-01-05&tz=Asia/Bangkok` — combined compact day.
- `GET /events?calendar_ids=1,2&time_min=...&time_max=...` — full event objects with
  `calendar_id` and `calendar_name`, `complete: true`.
- `GET /availability?calendar_ids=1,2&time_min=...&time_max=...` — unioned `busy`,
  complementary `free`, and `overlaps` between distinct calendar sources, all as
  start/end intervals. Overlaps also list `calendar_ids`. Free time does not
  apply working hours, minimum notice, duration or booking buffers.

All selected calendars require Viewer or higher. Unauthorized selection or any
provider error fails the whole request, rather than returning misleading partial
availability. Multiple registrations of the same Google calendar are read once.
Range aggregates allow at most 31 days. Complete event reads are bounded to 5,000
events / 50 Google pages per source and 5,000 total aggregate events; over-limit
reads return 422. Native complete reads currently scan up to 5,000 stored rows,
including tombstones. Single-calendar paginated `/events` remains available for
larger calendars. Aggregate reads are not cross-provider transactional snapshots.

## Change history and undo

Admin/Owner can call `GET /calendars/{id}/history`. The result has `changes` and
`next_page_token` (pass it as `page_token` for older rows). Each change includes
`id`, `actor_user_id`, `actor_name`, `event_id`, `action`, `at`, `before`, `after`,
`undo_of_id`, `undo_state`, and `undo: {available, reason, etag}`. Before/after
snapshots contain event details and remain restricted to calendar administrators.
Snapshots follow the owner-selected retention period; the default keeps them.
Expired snapshots cannot be used for undo. Audit metadata is retained.

Undo via `POST /calendars/{id}/history/{audit_id}/undo`, with `If-Match` equal to
the recorded `undo.etag` and JSON `{"send_updates":"none"}` (or `"all"`). A 200
response contains `event` (possibly null for a Google deletion) and
`undone_audit_id`. Availability of undo in history is a recorded capability, not
a guarantee the event still has that version; a newer live version returns 412.
Only one successful undo may apply to each source change.

Preview on the same endpoint with `{"dry_run":true,"send_updates":"none"}`
and the recorded `If-Match`. Editor+ can preview a known audit ID; applying undo
and full history/export remain Admin/Owner-only. Editors can list their own
completed booking changes as described below, but those entries expose no undo
ETag; an administrator must supply the ID and recorded ETag for other previews.
Preview defaults to `send_updates: "none"` when
omitted and performs live version, booking, pending-operation, series-exception
and restoration-proof checks. It writes no event, audit or undo claim. A valid
preview returns `available: true`, `current`, `current_etag`, `recorded_etag`,
`inverse` (operation, scope, changed fields and projected event; Google also
includes the provider patch), `notifications`, `limitations` and an `apply`
request hint. The projected event has no invented new ETag; an inverse that
deletes a Google event has a null projected event. Undoing a recorded deletion
instead projects the restored event. Native recurrence internals remain private and are
reported as `restores_recurrence_state` when relevant.

An unavailable preview uses the same error status as apply, with
`dry_run: true`, `available: false` and a reason once undo preparation begins;
authentication, malformed requests and missing history entries fail normally.
A stale live version returns 412. `dry_run` must be a JSON boolean; misspelled
fields or string values are rejected. To apply, remove `dry_run`, keep the
reviewed `If-Match`, and explicitly supply `send_updates`. No plan hash or
reservation is created: apply repeats every live check, so a successful preview
does not guarantee a later write will succeed.

Supported inverses: revert a recorded update, undo a recorded creation by deleting
it, and restore a native deletion. Google series creation/update now support
version-guarded undo when their recorded fields and current exception inventory
make it safe. Attendee changes cannot be automatically reversed over live RSVP
responses; invited event creation and changes that would discard exceptions are
refused. Splits, single-to-series conversions and whole-series deletes have no
safe automatic inverse; inspect their operation and provider state instead.

A Google standalone deletion can be restored only when Google retained the full,
unchanged versioned tombstone for an attendee-free event organized by that
calendar. The API records that proof immediately after deletion and checks the
same version again before restoring its status. It never manufactures a replacement
copy. Missing/truncated tombstones, guests/conferencing, recurring instances or
series deletions remain unavailable; inspect `undo.reason` and use Google Trash
where appropriate. Failed tombstone inspection does not turn a successful delete
into an error. Admin/Owner may filter history with `?event_id=<encoded-id>`.
The `actor` object includes the authenticated account's stable `gfavip_user_id`,
username at change and current username. Invitation-link responses are attributed
as `invitation_response`, not falsely credited to the calendar owner. External
Google edits have no trustworthy agent identity; the API does not invent one.
Legacy rows and changes outside this API lack restorable snapshots. Undo itself
is audited and is not automatically redoable. Provider timeouts/unknown outcomes
leave `undo_state: "uncertain"` (or `"pending"` after an interrupted attempt);
reconcile live state before any manual follow-up, never automatically retry.

New/rescheduled booking-created Google events use the host's configured timezone
label while preserving their UTC instants. Existing Google events are not rewritten
in bulk. Booking-managed events use the booking reschedule API below or the
existing Bookings interface; generic event PATCH/DELETE remain guarded.

## Read and reschedule bookings

These endpoints require Editor+ and the connected host's primary Google calendar.
The server verifies the primary calendar ID using the owner's Google connection.
Native calendars and secondary Google calendars cannot expose the host's bookings.
Reads do not require delegated rescheduling to be enabled. They include guest
name/email but omit private management links, guest tokens, notes and custom fields.

GET `/calendars/{id}/bookings?time_min=...&time_max=...` uses offset timestamps,
a positive range of at most 366 days, and filters by booking start in that range.
`status` is `confirmed` by default; `cancelled` and `all` are also supported.
Results sort by start then ID, with 100 bookings per page. Follow `page_token`
until `next_page_token: null` and `complete: true`; pagination is not a frozen
snapshot if bookings change concurrently. GET `/bookings/{booking_id}` under
the same calendar returns one booking. Each booking includes `start_utc`,
`end_utc`, host/guest timezones, `duration_minutes`, `google_event_id`, a quoted
`booking_version`, `reschedule_allowed`, `reschedule_unavailable_reason` and
`override_availability_allowed`.

An Editor/Admin can preview a slot even while delegation is disabled. Such a
response has `dry_run: true`, `applicable: false`, `can_apply: false`,
`plan_hash: null`, `code: "owner_permission_required"`, `required_permissions`
and `not_applicable_reason`. `availability.failed_checks` names all failed
policy checks, `blocked_checks` names the checks still blocking the requested
flags, and `override_authorized` is false. A diagnostic may describe a requested
override without granting it. It creates no operation/reservation, changes no
booking/event and cannot be applied: obtain the owner's permission, then request
a fresh applicable preview. Apply without permission still returns 403 with
`code: "owner_permission_required"`. Missing calendar access, provider failures,
invalid input and inconsistent booking/event state still fail normally.

The owner can reschedule directly. Delegation is off by default: only the owner
can enable `allow_editor_booking_reschedule: true` through calendar settings or
**Calendars → Owner preferences** on their primary Google calendar. This permits
both Editors and Admins to reschedule within normal availability. Disabling it
blocks further delegated writes/retries; the owner can inspect and reconcile any
already-started operation. A grant alone does not enable booking rescheduling.

POST `/calendars/{id}/bookings/{booking_id}/reschedule` to preview, for example:

```json
{
  "dry_run": true,
  "booking_version": "\"version-from-booking-read\"",
  "start_utc": "2030-01-08T10:00:00+07:00",
  "end_utc": "2030-01-08T10:30:00+07:00",
  "notify_guest": false,
  "reason": "Fictional example: requested earlier time"
}
```

Use the actual reviewed booking version and intended future time. Both bounds
must include an offset or `Z`; they normalize to UTC. The duration must remain
equal to the booking's current event-type duration. Only confirmed, standalone
timed Google events organized by the host are supported. The existing Google
event must agree with the stored booking time and duration; mismatches require
review rather than overwriting one side.

An applicable preview returns `dry_run: true`, `applicable: true`,
`can_apply: true`, `provider_written: false`, `plan_hash`,
booking/event versions, `before`/`after` bounds, `availability`, `notifications`
and warnings. It performs provider reads but creates no operation, booking change,
reservation or notification. Review this result, then remove `dry_run`, add
`confirm_reschedule: true` and the returned `plan_hash`, and submit the same
fields with a fresh `Idempotency-Key` (8–200 characters). A stale version/plan
returns 412; read and preview again. Changing the payload under an existing key
returns 409. Missing `booking_version` returns 428. Browser writes need CSRF;
Wallet bearer requests use their authenticated identity as usual.

`notify_guest` is required and must be a JSON boolean. False sends the Google
patch with `sendUpdates=none`; true requests `sendUpdates=all`, affecting **all
current Google attendees**, whose addresses appear in the preview. True requires
the booked guest to be present in the complete Google attendee list. Neither
choice sends a separate application booking email or legacy booking webhook.
Notification details use the neutral `booking_email: false` field in previews,
results and operation replays; it describes this operation only, not future
reminders. Google delivery itself is not verified. This choice does not disable future
booking reminders or existing member subscriptions to change feeds/webhooks.

The owner can separately enable `allow_editor_booking_override: true` in the
same preferences panel. It defaults to false and requires delegated rescheduling
to be enabled too. With both settings on, Editors/Admins may send
`override_availability: true` with a nonempty `reason` (maximum 1,000 characters).
The owner can use this option directly. Obtain approval for the specific exception;
delegation does not itself authorize choosing an otherwise unavailable time.

The preview lists exactly the failed checks being bypassed in
`availability.overridden_checks`: `working_hours`, `minimum_notice`,
`booking_horizon`, `slot_increment`, `request_only_hours`, `no_meetings`,
`daily_cap`, `in_person_location` and/or `conflicts`. An availability override
alone **still refuses an occupied slot**. Overlapping busy events/bookings require
an additional explicit `allow_conflict: true`, with the same override and reason.
Omitted/false `allow_conflict` keeps conflict protection, including for owners.
Non-applicable diagnostic previews include a conflict-consent warning only for
an actual conflict without that flag. Applicable preview/apply requests instead
refuse that conflict with 409; clear slots and already-acknowledged conflicts
receive no redundant warning.
This makes a one-booking exception without changing the booking page or event
type. Past starts, duration changes, stale data, unknown provider availability,
missing access and other operations' active reservations remain protected.

Every 403/409 from these calendar-scoped booking endpoints includes a short
`code`. Missing owner delegation returns `owner_permission_required`; blocked
rules return `availability_working_hours`, `availability_minimum_notice`,
`availability_booking_horizon`, `availability_slot_increment` or
`availability_conflicts`, alongside `failed_checks`. Other refusals use
`forbidden`/`conflict` unless a more specific code is available. A changed plan
before reserving returns 412; if the exact set of checks changes during a reserved
attempt, 409 `availability_changed` requires a fresh review. Never retry a policy
refusal as if it were a provider outage. Both settings are rechecked before writes;
turning off override delegation still allows ordinary delegated reschedules.

Apply conditionally PATCHes only start/end on the existing Google event using
its reviewed ETag, preserving attendees and conference details. Success is
reported after Google timing is confirmed and the local booking is saved;
reminder bookkeeping is reset for the new time. The event ID is unchanged.
The response includes booking/event details, `operation`, and `non_atomic: true`:
Google and the local database do not share a transaction. The durable operation
reserves the target interval and blocks conflicting booking changes while an
attempt is pending. It never recreates the Google event on failure.

After a timeout or partial error, inspect
`GET /calendars/{id}/bookings/{booking_id}/reschedule-operations/{operation_id}`
or list `/reschedule-operations` (newest 100; `truncated` signals more history).
Editors see their own operations; Admins/Owner see all for that booking.
Retry only the **original payload, plan hash, booking version and key**. Recovery
reads Google first and does not repeat a confirmed move/notification. Do not
start another operation to work around a pending one or treat an uncertain
provider outcome as a failed write.

Owner-only POST `.../reschedule-operations/{operation_id}/reconcile` accepts
`{"ack_partial_state":true,"booking_version":"<current quoted version>","provider_etag":"<current quoted ETag>"}`.
It only reads Google and can finish local bookkeeping when the original booking
version, intended Google bounds and preserved event content are proven; it
does not send another provider patch or notification. If proof fails, retain the
operation for investigation. The owner can POST the same acknowledgement shape
to `/abandon` only for a proven unattempted or definitively refused operation.
For abandonment only, `provider_etag: null` acknowledges a verified missing
Google event; reconciliation requires an existing versioned event.
An unknown or potentially applied provider write cannot be abandoned. Abandonment
releases local tracking/reservations, never rolls Google back, and never moves
the booking. Both actions require current reviewed versions and no active worker
lease. Operations expose `provider_write_state` (`not_attempted`, `rejected`,
`unknown` or `confirmed`); `provider_written` is null for an unknown outcome.
Completed reschedules are audited as `booking.reschedule`; use a new
reviewed reschedule to move back, not generic event undo. Admin/Owner history
includes `details.booking_reschedule` with the reason, exact `overridden_checks`,
`allow_conflict`, operation/booking IDs and the original `requested_by` identity
(user ID, GFAVIP ID and username captured at review). The top-level actor identifies
who completed the action, which may be an owner recovering an Editor's request.
`checks_source: "provider_intent"` means the checks were captured before the
provider attempt; `"legacy_review"` denotes the stored preview for older requests.
`legacy_plan: true` marks pre-delegation plans without a captured requester name.

Editors can GET `/calendars/{id}/history` (optional `event_id` and `page_token`)
to read only their own completed `booking.reschedule` entries. The response is
marked `access_scope: "own_booking_changes"`, includes terminal `complete` and
`next_page_token`, and filters ownership before pagination. Each entry gives
who completed it and the verified requester's reason, checks and conflict choice
under `details.booking_reschedule`. Original-requester access during owner
recovery is proven by the operation ledger, never a matching display name.
Entries omit event snapshots, private booking data and undo ETags; undo remains
unavailable for booking moves. Older direct-actor records without a verifiable
ledger link may show `details.booking_reschedule: null`. Other members' changes
and settings are hidden. Full history, export and undo apply remain Admin/Owner
only. Current Editor access is required even for an earlier personal change.

These audit details survive event-snapshot retention cleanup. Owner setting
changes also record both delegation flags before/after. An explicitly authorized
operator configuration is labelled `operator_configuration`, never attributed as
an authenticated owner action.

The bookings list reflects local booking status; it is not a live Google status
synchronization. If a linked Google event is cancelled but the booking remains
confirmed, request an operator reconciliation. The operator must review a
versioned cancellation preview, then recheck the exact host/calendar/event,
booking version and provider ETag under booking locks. This updates only local
status plus audit metadata; it does not alter Google, send notifications or
remove reminder history. A missing, inaccessible or recurring event alone is
not proof of cancellation. This maintenance flow has no agent write endpoint.

## Repair legacy booking timezone labels

This is an explicit one-off migration, never an automatic deployment task.
Editor+ may POST `/calendars/{id}/booking-timezones/preview` with ISO `time_min`
and `time_max` (up to 366 days; at most 100 confirmed bookings per preview).
The server verifies the connected host's primary Google calendar and the stored
booking identity/time. It proposes only legacy UTC-labeled standalone events.
Already-correct events are skipped; mismatched timing, recurrence and other
unsupported cases are reported for review. Provider failures do not become empty
previews. The preview makes no writes and includes old/new bounds, `plan_hash`,
`preview_token` and skipped reasons. Review the timezone and instants.

Within 20 minutes, POST `/calendars/{id}/booking-timezones/apply` as the same
actor with the returned token/hash, `confirm_repair: true` and
`send_updates: "none"`. The notification choice is mandatory and cannot be
changed to `all`. Each patch uses the reviewed ETag and changes only start/end
representations, preserving their actual instants. Attendees, conferencing and
booking records remain intact. Changing the host timezone or booking invalidates
the plan. This narrowly scoped endpoint is the exception to the generic booking
write guard; it cannot move, retitle or delete a booking.

The repair is not atomic across events: inspect every per-event result in HTTP
200/207. A stale event needs a fresh preview. For an unknown provider outcome,
retry the same reviewed token while valid; an already-correct event is read and
skipped without a second write. No attendee notifications are sent. The response
reports `actual_times_changed: false`. Preview tokens contain calendar details;
keep them private. Production events are not changed merely by installing this
release; the caller must explicitly review and apply a plan.

## Current limits

Google events associated with Structure Cal bookings must use the booking
reschedule API or Bookings interface so guest details and reminders stay
consistent. The generic event API returns 409 for those writes. Google recurring events support creation, occurrence edits,
whole-series PATCH and previewed future splits as described below.
Google attendees can be added with explicit notification/merge choices below;
conference creation remains outside this API. Native invitation endpoints manage
attendees separately. There is no automatic two-way native/Google synchronization.

Owned native events, including recurring occurrences, block booking availability.
The primary Google calendar always participates; the owner may explicitly enable
additional registered Google calendars in calendar preferences. Sharing alone does
not change booking policy. There is no calendar deletion/ownership-transfer endpoint.

The accepted development sequence is public at https://cal.structure.info/docs/roadmap.md.

## Native recurrence

Create a native event with `recurrence: ["RRULE:FREQ=WEEKLY;COUNT=12"]` inside
`event`. Supported frequencies are DAILY, WEEKLY, MONTHLY and YEARLY. RRULE
accepts FREQ, INTERVAL, COUNT, UNTIL, BYDAY, BYMONTHDAY, BYMONTH, BYSETPOS and WKST;
RDATE and EXDATE are also supported. Timed series require an IANA `start.timeZone`. Recurrence follows
local wall time across daylight saving changes, while each event retains its
elapsed duration. Nonexistent local times are skipped.

Lists, agendas, busy queries, reminders and booking availability expand native
series. An occurrence returns `recurringEventId`, `originalStartTime`, and the
master's `etag`. Treat occurrence IDs as opaque. Fetch the master with its
`recurringEventId` to read the original rule.

For recurring native PATCH/DELETE, explicitly choose envelope `scope`:
`"occurrence"`, `"future"`, or `"series"`. Occurrence and future operations use
the returned occurrence ID. `If-Match` guards the entire master, including its
exceptions and future segments. Series text edits preserve occurrence overrides;
structural changes that would discard exceptions require a separate explicit
`reset_exceptions: true` acknowledgement. Do not infer that acknowledgement from
an ordinary time change. Google series use the separate contract below.

Expansion is bounded to 5,000 occurrences and 50,000 generation steps per complete
read, at most 1,000 exceptions and 100 future segments. Unsupported rules or a
request beyond the supported 100-year generation horizon return an error rather
than a falsely complete empty calendar. Future edits selected on an RDATE-only
date require an explicit replacement recurrence or occurrence-only scope.

## Google recurring creation and series edits

Create a recurring Google event by including `event.recurrence` in the existing
POST envelope, with a fresh `Idempotency-Key`. For example, the last Friday of
every month, 15:00–18:00 Bangkok time, from October 2026 through June 2027:

```json
{
  "event": {
    "summary": "Monthly meeting",
    "start": {"dateTime": "2026-10-30T15:00:00+07:00", "timeZone": "Asia/Bangkok"},
    "end": {"dateTime": "2026-10-30T18:00:00+07:00", "timeZone": "Asia/Bangkok"},
    "recurrence": ["RRULE:FREQ=MONTHLY;BYDAY=-1FR;UNTIL=20270630T235959Z"]
  },
  "send_updates": "none",
  "duplicate_check": true,
  "duplicate_timezone": "Asia/Bangkok"
}
```

This creates one Google series with nine occurrences; it does not convert or
remove any existing single events. Review duplicate matches if the dates were
previously entered separately. Use the same idempotency key and payload on a
retry. Lists and agendas continue returning expanded occurrences with opaque IDs.

To change the whole series, read the master using an occurrence's
`recurringEventId`, then PATCH that master ID with `scope: "series"`, its current
`If-Match`, the changed event fields and an explicit `send_updates` choice.
Keep the master's original date when changing a daily start/end time; replacing
that date changes the series anchor. Occurrence-addressed series edits accept
text fields only and also require the current `master_etag` in the envelope.
An ordinary occurrence edit continues to use that occurrence's own ID and ETag.

For Google recurrence, `duplicate_check: true` checks every proposed occurrence,
including later dates already stored as singles. The complete check requires a
finite RRULE (`COUNT` or `UNTIL`), at most 1,000 proposed occurrences, and the
normal complete provider-read limits (5,000 events / 50 pages). Unsupported or
oversized checks return 422; they do not silently check only the first date.
Unbounded series may be created with the duplicate check disabled explicitly.
Supported rule fields and the 100-year expansion horizon match native recurrence.
Timed Google series require IANA `timeZone` in both bounds.

When shifting a series start without supplying a replacement recurrence, inherited
UNTIL/RDATE/EXDATE times shift with its local start so the final occurrence is
retained. A supplied recurrence is authoritative. Whole-series pattern changes
are refused if existing occurrence exceptions would be lost. Series operations
are audited; supported single-resource inverses are described under history/undo.

### This and following

First read the selected occurrence and its master. PATCH the occurrence ID with
its current `If-Match` and a preview envelope, for example:

```json
{
  "scope": "future",
  "master_etag": "\"master-version\"",
  "event": {
    "start": {"dateTime":"2027-01-29T16:00:00+07:00","timeZone":"Asia/Bangkok"},
    "end": {"dateTime":"2027-01-29T19:00:00+07:00","timeZone":"Asia/Bangkok"}
  },
  "send_updates": "none",
  "dry_run": true
}
```

A preview makes no provider writes. Review `preview.future_event`,
`source_recurrence`, `cutoff`, and `warnings`. To apply, repeat the same request
with a new `Idempotency-Key`, remove `dry_run`, and add `confirm_split: true` and
the exact `preview.plan_hash` as envelope `plan_hash`. Retain both reviewed ETags.
A changed source or preview requires a new review before submission. Selecting
the first occurrence requires whole-series scope instead.

Google needs two separate writes: create the successor, then trim the original.
Temporary duplicate future occurrences can exist between them. The result includes
`operation`, `event` (successor), `source_event`, and `non_atomic: true`. On an
interrupted request, retry the identical confirmed request with its **original**
key, payload and If-Match; do not start a new split or refresh its versions.
The persisted operation reconciles completed provider writes before continuing.
Other API edits to either series stay locked while recovery is pending.

Only the organizing Google calendar can split a series. Future exceptions,
conference data, attachments, special event types and other metadata that cannot
be copied safely cause an error before writes. Existing attendees require a
reviewed `acknowledge_attendee_reset: true` and `send_updates: "all"`, because a
new series re-invites them and their responses can reset. Unsupported cases need
individual edits or Google's calendar interface. Exception inventory is bounded
to 5,000 stored provider records / 50 pages and fails closed if incomplete.

### Inspecting or abandoning an interrupted split

- `GET /calendars/{id}/operations` lists up to 100 operation statuses. Actors see
  their own; Admin/Owner can see all operations on that local calendar.
- `GET /calendars/{id}/operations/{operation_id}` reads status. Add `inspect=true`
  to also read the current `source_event` and `target_event` without writing.
- The original actor can resume using the exact confirmed PATCH above, while
  retaining Editor access. Admin/Owner can inspect an orphaned operation even
  if that actor has lost access.
- To stop recovery after reviewing both live resources, POST to
  `/calendars/{id}/operations/{operation_id}/abandon` with
  `acknowledge_partial_state: true`, `source_etag`, and `target_etag` (null only
  for a resource confirmed missing by inspection). The actor or Admin/Owner can
  abandon only when the lease and provider outcome make it safe. Active or
  unresolved provider writes refuse abandonment.

Abandon releases the API lock; it **does not roll back or modify Google events**.
The response says `provider_written: false` and `rolled_back: false`. Review any
remaining duplicates or partial split before making a separate intentional edit.
Never use abandonment to claim that a failed split was undone.

## Linked single events and bulk changes

Use an envelope `series_key` on create to group related standalone events, such
as `"series_key": "monthly-meetings-2026"`. The key is local to a calendar,
1–100 ASCII letters/numbers/dots/colons/hyphens/underscores, beginning with a
letter or number. A group has at most 100 members; each event belongs to at most
one group. Groups do not turn single events into a Google or native recurrence.
Recurring and booking-managed events cannot be group members.

For existing singles, explicitly link reviewed event versions:

```http
POST /api/v1/calendars/{id}/event-groups/monthly-meetings-2026/members
Content-Type: application/json

{"events":[{"id":"first-event","etag":"\"first-version\""},{"id":"second-event","etag":"\"second-version\""}]}
```

Linking requires Editor+ and makes no Google event change. The complete group is
read with `GET /calendars/{id}/events?series_key=monthly-meetings-2026` (Viewer+).
It returns `events`, per-member `members` statuses and an `expected_events` list
of IDs/ETags to review before a bulk PATCH. Missing members have a null ETag and
an error status; do not treat the returned successful events as the full group.

PATCH the same group URL with a new `Idempotency-Key`, the exact reviewed
`expected_events`, explicit `send_updates`, and your changes. For example, to
make every timed meeting three hours long while retaining its date and start:

```json
{
  "expected_events": [{"id":"first-event","etag":"\"first-version\""},{"id":"second-event","etag":"\"second-version\""}],
  "duration_minutes": 180,
  "send_updates": "none"
}
```

`event` may change summary, description or location. Timed events accept
`shift_minutes` and/or `duration_minutes`; all-day events accept `shift_days`
and/or `duration_days`. Minute shifts use elapsed time and retain named timezone
labels, including their new UTC offset after DST. Day changes retain exclusive
all-day ends. Do not send one absolute start/end to overwrite every event date.
Use optional `target_ids` to select a subset while still supplying the full
reviewed group membership.

The batch is **not atomic**. HTTP 200 means all selected events succeeded; 207
returns per-event `status`, `state`, `event`/new ETag or `error`. Inspect every
result. An operation stores the original versions and absolute intended patches;
retry the **identical payload and key** to resume/reconcile it. Already successful
items are not shifted twice. Do not refresh all ETags and retry the whole shift
with a new key after partial success. For a deliberate follow-up on known failed
items, reread the group, review `expected_events`, select only those `target_ids`,
and use a new key. An uncertain provider result needs reconciliation first.

Read operation status with
`GET /calendars/{id}/event-groups/{series_key}/operations/{operation_id}`.
Operation status requires current calendar access and is visible to the original
actor or Admin/Owner. Membership changes stop unfinished retries until reviewed. A group
lookup includes `next_page_token: null` and `membership_complete: true`;
`complete` is false if any member could not be read. It includes read-only
`series_key` metadata; raw Google private properties
are not returned.

## Convert reviewed singles into a Google series

Editor+ can POST `/calendars/{id}/convert-to-series`. This preserves an existing
set of **1–100 standalone events** as one finite series. **The caller must supply
the recurrence rule** in `event.recurrence`, plus the first occurrence's start/end.
The server validates that proposal; it does not infer or suggest an RRULE.
First list and review
every source ID and ETag, including all members if supplying a `series_key`.
Preview with an explicit notification choice:

```json
{
  "dry_run": true,
  "send_updates": "none",
  "series_key": "fictional-school-series",
  "events": [
    {"id":"fictional-first","etag":"\"first-version\""},
    {"id":"fictional-second","etag":"\"second-version\""}
  ],
  "event": {
    "start":{"dateTime":"2030-01-04T15:00:00+07:00","timeZone":"Asia/Bangkok"},
    "end":{"dateTime":"2030-01-04T18:00:00+07:00","timeZone":"Asia/Bangkok"},
    "recurrence":["RRULE:FREQ=WEEKLY;COUNT=2"]
  }
}
```

Omit `series_key` for a reviewed list without a group. A group key never selects
unreviewed events implicitly. The originals must have identical content and
settings; omitted content is inherited. The proposed rule must reproduce **every
original interval exactly**, with no additional dates. All-day bounds remain
date-only; timed comparisons use actual instants. Changing content or adding
dates is a separate subsequent edit. Organizer-owned, attendee-free default
events are supported. Booking-managed events, conferences, attachments, existing
recurrence and unsupported provider metadata are refused rather than discarded.

The preview returns `provider_written: false`, top-level `plan_hash`, `original_events` sorted by start time
(then ID for ties), the validated caller-proposed `event`,
`occurrence_count`, `send_updates`, `warnings` and `non_atomic: true`. Review it,
then resend the same request without `dry_run`, adding `confirm_conversion: true`
and that `plan_hash`, with a fresh `Idempotency-Key`. Apply requires explicit
`send_updates: "none"` or `"all"`; use `"none"` for no attendee notifications.
Changed source versions or group membership invalidate the plan.

The server creates the replacement, reads it back, and verifies its complete
Google instance inventory before conditionally deleting any original. Each
deletion rechecks the replacement and source ETag. The result contains `event`,
`removed_event_ids` and `operation`. This is a multi-resource operation: external
Google edits cannot be locked, and failures can leave a replacement plus some
originals. Original IDs and group membership do not transfer to the series.

For an interrupted operation, retry only the identical confirmed payload with
the original key. Successful deletions are not repeated; changed or unexpectedly
missing/restored originals stop recovery for review. Never use a new key to
repeat the conversion. Inspect:

- `GET /calendars/{id}/conversions` — your operations; Admin/Owner can see all on
  that calendar. At most 100 are returned, with an explicit `truncated` flag.
- `GET /calendars/{id}/conversions/{operation_id}` — durable status and per-source
  states. Add `?inspect=true` for current Google replacement/original versions.
- `POST /calendars/{id}/conversions/{operation_id}/abandon` — after inspection,
  the original actor or Admin/Owner can acknowledge partial state with
  `ack_partial_state: true`, current `target_etag` (or null), and an
  `original_etags` object mapping **every** original ID to its current ETag (or
  null). An active lease or unresolved provider write blocks abandonment.

Abandonment only releases local locks; it does not change or roll back Google
events. Only the original actor, while still an Editor+, can resume. There is no
automatic conversion undo. Target confirmation and each source removal are
audited separately so partial outcomes remain visible.

## Import, export and Google copies

| Method | Path | Contract |
|---|---|---|
| GET | `/calendars/{id}/export.ics` | Viewer+, full calendar; optional paired `time_min`/`time_max` for a range snapshot |
| POST | `/calendars/{id}/imports/preview` | Native Editor+, `{"ics":"BEGIN:VCALENDAR..."}`; no writes |
| POST | `/calendars/{id}/imports/google/preview` | Native destination Editor+, `{"source_calendar_id":2,"event_ids":["google-event-id"]}`; requires read access to Google source |
| POST | `/calendars/{id}/imports/apply` | `{"preview_token":"..."}` from the reviewed preview |

Preview returns event summaries with `action: create|skip|conflict`, `warnings`,
`can_apply` and a member/destination-bound token expiring after 20 minutes. Apply
is atomic, rechecks access, and returns `created_ids` and `skipped_ids`. Matching
UID/source identities are stable across retries. Changed or deleted existing
imports conflict rather than overwriting local changes. Limits: 1 MiB parsed
calendar and 1,000 event components; unsupported semantics fail validation.

Copies retain readonly `source` provenance and are independent native data.
Google copy accepts 1–100 selected IDs. Selecting an occurrence copies its whole
series and exceptions; review the preview before applying. Unsupported special
Google events, attachments or conferencing fail instead of silently losing data.
Google is unchanged; there is no automatic ongoing sync. Imported attendees are
informational and imported alarms are disabled. Import never sends invitations.
Review warnings before applying. A future-segment export uses separate compatible
series plus Structure Cal metadata for exact reimport; editing stale metadata
outside Structure Cal can require removing it and importing independent series.

Exports include one `VTIMEZONE` for each emitted `TZID`, including timezones used
by recurrence dates and exceptions. Definitions use the server's installed IANA
history and future rules, so clients do not need to infer missing timezone data.
The initial coverage year is derived from the exported events, not fixed to 2025.
Regression coverage includes native and Google range snapshots before 2025 and
spring/fall daylight-saving transitions, including historical 2006 rules.
UTC and date-only properties need no timezone definition. The 1 MiB export limit
includes definitions, and an export may reference at most 64 named timezones.
When a complex timezone's future rules cannot be represented indefinitely, a
full unbounded series export returns 422; use a finite recurrence or bounded
date-range snapshot. Unsupported date/transition coverage fails explicitly.

Local times in the second occurrence of a repeated DST hour are ambiguous in
iCalendar. Standalone and range snapshots encode those instants in UTC to keep
the time exact. An unsafe recurring local-time export is refused rather than
changing its wall-time recurrence; use a bounded snapshot for those events.

### Range exports and checked imports

Omitting bounds exports full native/Google series. Providing both `time_min` and
`time_max` (offset timestamps, up to 366 days) exports complete event bounds for
occurrences overlapping `[time_min,time_max)`. This is a **standalone occurrence
snapshot**, including moved exceptions; it does not carry future RRULE dates or
recreate a recurring series on import. Stable occurrence UIDs make repeated
snapshots identifiable. `X-Calendar-Export-Mode` is `full-series` or
`range-snapshot`; snapshot filenames end in `-snapshot.ics`.

Range exports are bounded to 1,000 output occurrences, 1 MiB and 20 Google pages;
incomplete results fail with 422. All exports explicitly report
`X-Calendar-Export-Warnings: reminders-omitted` and include the omission in their
calendar description. Export does not send invitations. Imported alarms remain
disabled, and attendee data is informational.

Native import previews, including selected Google copies, accept
`duplicate_check: true` and optional `duplicate_timezone`. For example:

```json
{"ics":"<complete ICS file text>","duplicate_check":true,"duplicate_timezone":"Asia/Bangkok"}
```

The check compares normalized title and local start date within the incoming
batch and against the destination, in addition to stable UID/body checks. It
expands finite recurrences and moved/cancelled exceptions; unbounded rules or
checks beyond 1,000 proposed occurrences / 1,000 destination records fail with
422. Conflicts return `conflict_reason: "possible_duplicate"`, counts and bounded
`matches`, and block that plan. For an intentional duplicate, review a fresh
preview with explicit `duplicate_check: false`; there is no automatic override.

Apply still accepts only its signed `preview_token`. It rechecks current
permissions, destination content/version and duplicates before an atomic native
insert. An intervening destination change returns 409 requiring a new preview;
completed identical retries remain safe. Import destinations are **native only**.
Google remains a read-only export/copy source for this workflow, not an implicit
sync target.

## Per-event reminders

Google and native create/PATCH accept `event.reminders`:

```json
{"reminders":{"useDefault":false,"overrides":[{"method":"popup","minutes":30},{"method":"email","minutes":60}]}}
```

Use at most five distinct popup/email overrides, each 0–40,320 minutes before
start. `useDefault: true` is Google-only and cannot be combined with overrides.
An explicit empty override list with `useDefault: false` clears reminders;
omitting reminders preserves them on PATCH. Use ordinary ETags, recurrence scopes
and explicit `send_updates` for event writes. Google delivers reminders for the
connected calendar copy; attendee update emails are a separate notification choice.

Native calendars have no hidden default reminders. Their event overrides require
an explicit subscription with `kind: "reminder"` and
`options: {"use_event_reminders":true,"timezone":"Asia/Bangkok"}`. Omit the
subscription-wide `offset_minutes` in this mode. Destination `feed` delivers
popup-method overrides to the authenticated feed, also readable in the app's
Notifications panel; it is **not an operating-system/browser push popup**.
The native owner may explicitly choose destination `email` to deliver email-method
overrides to their own account email when server email delivery is enabled.
Other members cannot subscribe someone else's address, and changing that account
email cancels stale queued delivery. Merely adding an event override subscribes
nobody and sends nothing. The worker rechecks event time, override and access
before delivery; email is at-least-once with a stable Message-ID. Zero-minute
native reminders have a one-minute delivery window after start.

Existing change webhooks are available now. To avoid polling a change feed, POST
`/calendars/{id}/subscriptions` with `kind: "changes"`, `destination: "webhook"`
and the user's chosen public HTTPS `url`, then securely save the returned signing
secret. Follow the signature and deduplication contract below. No webhook is
registered automatically by event creation or deployment.

## Google attendees and the calendar's RSVP

Google create may include `event.attendees` containing up to 100 unique emails
with optional `displayName` and `optional` boolean. Do not author other people's
`responseStatus`; new guests begin in needsAction. PATCH requires explicit
envelope `attendees_mode: "append"` or `"replace"`, the current `If-Match` and
`send_updates`. Append retains existing guests; replace removes omitted guests
while preserving the calendar's organizer/self entry. Retained guests keep their
RSVP and provider metadata. Only the organizing Google calendar can edit guests,
and a truncated attendee list is refused. Example PATCH envelope:

```json
{"event":{"attendees":[{"email":"guest@example.com","displayName":"Guest"}]},"attendees_mode":"append","send_updates":"all"}
```

Use `all` when the user wants invitations/updates sent; `none` explicitly requests
no attendee notifications. Native calendars continue to use the invitation
endpoint below, which separately requires `send_invitations: true`.

GET `/calendars/{id}/events/{event_id}/calendar-rsvp` returns `calendar_rsvp`,
`my_rsvp`, `calendar_attendee_email`, `response_target`, `rsvp_applicable`,
`rsvp_reason_code`, `rsvp_reason` and the event. Viewer+ can
read; Editor+ can POST `{"response_status":"accepted","send_updates":"all"}`
with the current `If-Match` (also `tentative` or `declined`). On Google this changes
only the addressed calendar copy's `self` attendee, preserving other guests. It
does not substitute the agent's email. Use an explicit `scope: "series"` on a
master or `scope: "occurrence"` on an instance; standalone events omit scope.

Check `rsvp_applicable` before POST. If false, `response_target` is null and
the reason explains why a reply is unavailable: `booking_managed` (use Bookings),
`no_calendar_attendee` (no self/owner invitation), or `no_active_invitation`
(native metadata has no active invitation). For example, an organizer's booking
may have guests but no self attendee; null `calendar_rsvp` does not mean it needs
an RSVP. Booking-managed events cannot be replied to through this endpoint,
even if they have a self attendee. POST returns 409 with `code`,
`rsvp_applicable: false` and `response_target: null` without writing. A true
applicability result describes the event; current Editor access, ETag, recurrence
scope and any pending operation guards are still required at write time.

For native calendars, the target is the owner email on an active native
invitation, with the master ID and `send_updates: "none"`. Imported attendee
metadata alone cannot authorize an RSVP, and no external-organizer email is sent.
The existing `/rsvp` endpoint remains the authenticated member's own response.
`my_rsvp` always describes that authenticated member; `calendar_rsvp` describes
Google self or the native owner invitation. Null means unavailable/no match,
never an automatic decline. Ask only if the user has not made clear whose RSVP
they want changed.

## Native invitations and RSVP

For a saved native event, Editor+ may call
`POST /calendars/{id}/events/{event_id}/invitations` with its current `If-Match`
and `{"send_invitations":true,"recipients":[{"email":"guest@example.com","name":"Guest"}]}`.
This is an explicit request to queue email. Use the master ID for recurring
events; invitations and RSVP apply to the whole series. Invitations do not grant
calendar membership. GET the same endpoint for recipients, response and delivery
status; DELETE `.../invitations/{invitation_id}` with `If-Match` revokes the link
and removes the attendee.

To explicitly resend a sent/failed invitation, POST
`.../invitations/{invitation_id}/resend` with current event `If-Match` and
`{"send_invitations":true}`. Resend renews the private link, invalidates the old
link, and is bounded by a cooldown and resend limit. Queued/in-flight messages
already retry automatically; do not create parallel resend attempts.

Each email has a private RSVP link valid for 30 days. External guests can reply
without Google or GFAVIP and see only that event's title, time and location.
Authenticated invited members may POST `.../events/{event_id}/rsvp` with
`{"response_status":"accepted"}` (`tentative` and `declined` also supported).
The identity must match the invited email; callers cannot reply for someone else.

Delivery is asynchronous with bounded retries and live inviter permission checks.
States include queued, sending, retry, sent, failed and cancelled. Email transport
is at-least-once; a stable Message-ID helps recipients deduplicate uncertain
retries. Ordinary event changes do not automatically email attendees. Native
invitations require configured email delivery on the deployment.

## Opt-in notifications, reminders and agendas

Subscriptions belong to the authenticated member. Any calendar member can POST
`/calendars/{id}/subscriptions` with:

```json
{"kind":"agenda","destination":"feed","options":{"time":"08:00","timezone":"Asia/Bangkok"}}
```

Kinds: `changes`, `agenda`, `reminder`. Reminder options are
`{"offset_minutes":30,"timezone":"Asia/Bangkok"}` (1–10,080 minutes). Changes
need no options. Destination `feed` is authenticated polling, not browser push.
For `webhook`, supply `url` as a public HTTPS endpoint on port 443. Creation returns
a `signing_secret` **once**. Save it securely; URLs and secrets are encrypted at rest.

- GET `/calendar-notifications/subscriptions` lists your accessible subscriptions.
- GET `/calendar-notifications/subscriptions/{id}/feed?after=0` returns
  `notifications`, `next_cursor`, and `has_more`; follow the cursor until caught up.
- DELETE `/calendar-notifications/subscriptions/{id}` turns your subscription off.

Calendar changes identify `source: app` for recorded app edits and `source: google`
for provider-observed changes. Google does not reliably identify who made an edit.
New subscriptions start with new changes, not a historical backfill. Google sync
uses watch wakeups plus periodic incremental recovery and rebuilds expired sync
tokens. External observation does not create undoable before/after history.

The worker checks about once per minute. Agendas expire at the end of the local
day; positive-offset reminders expire at event start and revalidate
moved/cancelled occurrences. Zero-minute native overrides use the one-minute
window described above.
Revoked membership disables access and queued delivery. Credentials are never
included in payloads.

Webhooks are at-least-once. Deduplicate `Idempotency-Key` / `notification_id`.
Verify `X-Structure-Signature: t=<unix-seconds>,v1=<hex-hmac>` against
HMAC-SHA256 of `timestamp + '.' + notification_id + '.' + raw_body` using the
signing secret; compare in constant time and enforce a suitable replay window.
Only a successful HTTP response acknowledges delivery. Redirects are not followed.

## Search, conflicts and suggestions

These Viewer+ queries use explicit `calendar_ids` and ISO `time_min`/`time_max`,
with the same maximum 31-day range as combined views:

- GET `/search?q=school&calendar_ids=1,2&time_min=...&time_max=...` searches title,
  description and location, returning complete bounded event objects.
- GET `/conflicts?...` returns overlapping pairs, including pairs within one
  calendar. Transparent events and merely touching intervals are excluded.
- GET `/availability/suggestions?...&duration_minutes=30&buffer_before_minutes=0&buffer_after_minutes=15`
  applies the caller's configured working hours and selected calendars' busy time.
  Optional `tz` defaults to caller timezone; `increment_minutes` defaults to 15.
  At most 200 slots are returned (`complete:false` means narrow the range).

Suggestions are a read-time comparison, not a reservation or cross-calendar lock.
Recheck before creating an event. They do not infer other members' working hours.

## Owner preferences and history maintenance

Admin/Owner can GET `/calendars/{id}/settings`; only Owner may PATCH:
`{"block_bookings":true,"snapshot_retention_days":90}`. The separate
`allow_editor_booking_reschedule` and `allow_editor_booking_override` booleans
both default to false and can be enabled only for the owner's verified primary
Google calendar. Both appear in the owner preferences panel. The first permits
ordinary Editor/Admin reschedules; both are required for delegated availability
overrides. Neither changes membership or public availability rules. For example,
`{"allow_editor_booking_reschedule":true,"allow_editor_booking_override":true}`
enables both permissions; only the Owner can apply this settings request.
The independent `allow_editor_booking_policy_edits` boolean defaults false on
native and Google calendars; it enables reviewed host policy editing for
Editor/Admin members. See Travel and booking preferences for the apply protocol.
The calendar-local all-day default is edited through reviewed booking-policy,
not through this permission-setting endpoint.
`block_bookings` applies
only to that owner's additional Google calendar. All required provider busy
responses must succeed before booking confirmation. Snapshot retention accepts
30–3,650 days or null to keep snapshots; cleanup removes old before/after content,
retains audit metadata, and preserves pending/uncertain reconciliation evidence.

Admin/Owner may GET `/calendars/{id}/history/export` for a JSON download (up to
10,000 entries; use paginated history beyond that). For an `uncertain` Google
undo, POST `/calendars/{id}/history/{audit_id}/reconcile` with `If-Match` equal to
the recorded after etag. This only reads Google and records whether the inverse
is applied, not applied, or unresolved. It never edits the provider. Pending
attempts and unrelated newer states require manual investigation.

## Native CalDAV

The endpoint is `https://cal.structure.info/caldav/`, discoverable through
`/.well-known/caldav`. It exposes accessible native calendars only. Each request
rechecks the same GFAVIP member roles as the JSON API.

Create a client connection in Calendars, or POST `/caldav/credentials` with
`{"label":"My calendar client","expires_in_days":365}`. The response contains
`username`, `app_password`, `url`, `calendar_home`, and credential metadata.
Passwords are shown once, stored hashed, expire after 1–365 days, and may be
revoked with DELETE `/caldav/credentials/{credential_id}`. GET `/caldav/credentials`
lists metadata only. Use HTTP Basic over HTTPS with the returned settings;
CalDAV clients can also use a `caldav_...` bearer application password. GFAVIP
bearer identity remains supported for programmatic use. Keep credentials out of URLs.

Initial support covers principal/calendar-home discovery, Depth 0/1 PROPFIND,
VEVENT calendar-query and calendar-multiget REPORTs, and event-resource GET/PUT/
DELETE. New resources require `If-None-Match: *`; replacing or deleting requires
the current strong `If-Match`. Resource filenames end in `.ics`; recurrence and
exceptions are represented together. UID and resource identity remain unique
within the calendar. Ordinary reads do not create resource mappings.

This is a bounded VEVENT implementation, not a claim of universal calendar-client
compatibility. It does not advertise scheduling/iTIP, VTODO, WebDAV locks,
sync-collection, arbitrary query filters, or calendar-collection creation.
Unsupported or lossy PUT input is rejected instead of silently dropping fields.
CalDAV writes never email invitees. Protocol tests are automated; individual
Apple Calendar/Thunderbird releases still need their own compatibility checks.

## Travel and booking preferences

Owners use **Travel & booking rules** in the dashboard, or the endpoints below.
Editors and Admins can read and preview. The owner may turn on **Allow delegated
policy edits** in that dashboard or Calendars → Owner preferences, through
`PATCH /calendars/{id}/settings` with `allow_editor_booking_policy_edits: true`.
In Travel & booking rules, changing the checkbox saves immediately. Wait for
“Saved: on/off” beside it: the dashboard reads back both settings and booking-policy
before confirming. The calendar selector includes provider and local ID. Failed
or unverified saves require **Reload saved settings** before another change.
Permission changes do not increment the booking-policy content revision; revision 0
can still have delegation enabled.
This permission defaults to false and is independently audited; only the owner
may grant/revoke it. It lets Editor+ members of that calendar change the host's
trips, working hours and booking rules across all booking pages. It does not
grant booking-reschedule/override permission. Policy undo never restores a
permission grant.

Rules, working hours, trips and type preferences are **host-wide**. The exception
is `all_day_blocks_bookings`, which belongs to the addressed calendar. No policy
write moves an existing event or notifies an attendee. Examples are fictional
and are never installed automatically.

`GET /calendars/{id}/booking-policy` returns `policy`, `policy_etag`,
`scope: "host"`, `allow_editor_booking_policy_edits`, `can_apply`, and limits.
`policy` includes `home_city`, `buffer_before_minutes`, `buffer_after_minutes`,
`max_meetings_per_day`, `no_meetings`, `working_hours`, `all_day_blocks_bookings`,
`revision` and `etag`. The HTTP ETag matches `policy_etag`: a quoted global host
revision and full-state hash. Read it afresh before each proposed mutation.
Trip and event-type GETs also return this global ETag; their individual resource
`etag` fields are informational and cannot be used for a policy write.

### Review every policy write

This replaces the original direct owner-write contract. **All** policy PATCHes,
trip POST/PATCH/DELETEs, type-rule PATCHes and policy undo—including owner writes—
require this sequence:

1. Read `/booking-policy` and retain `policy_etag` including its quotes.
2. Send the desired write URL/method with its ordinary fields plus `dry_run:true`
   and `If-Match: <policy_etag>`. DELETE and undo use only the control fields.
3. Review `before`, `after`, `changes`, `can_apply`, and `plan_hash`.
   `provider_written:false` confirms no provider write. Previews create no
   policy rows, operation receipts or audit entries. With delegation off,
   Editors still receive previews with `can_apply:false`.
4. Apply the exact fields with `confirm:true`, the returned `plan_hash`, the
   same `If-Match`, and a fresh `Idempotency-Key` (1–128 ASCII letters/digits or
   `.`, `_`, `:`, `-`). **Omit `dry_run` on apply.**

For example, the two bodies for a fictional home-city change are:

```json
{"home_city":"Example home","dry_run":true}
```
```json
{"home_city":"Example home","confirm":true,"plan_hash":"<returned hash>"}
```

Apply returns `applied:true`, `provider_written:false`, `audit_id`, `action`,
`scope`, the new `policy_etag` and `policy`. Trip writes add `trip` or
`deleted_trip_id`; type writes add `event_type`. Trip creation returns 201;
other applies, including trip DELETE, return 200 with JSON. Retain the exact
actor/calendar/key/body/If-Match for uncertain retries; an identical retry returns
the original receipt even after later edits. Different input under that key is
409 `idempotency_conflict`. Disabled delegation refuses apply with 403; stale
policy returns 412, missing If-Match 428, missing reviewed controls 400, and changed
plan 409. Re-read and re-preview on stale state; never auto-confirm a changed plan.
All apply authorization is checked again after acquiring the host lock.

Policy evidence is bounded to 200 trips, 500 event types, 500 owned calendars and
1 MiB of full serialized state; oversized reviews fail closed. No Google write
or attendee notification is part of policy apply or undo.

### Fields and weekly hours

PATCH accepts one or more of the policy fields above, excluding revision/etag.
`working_hours` uses the weekly-window format below, with **one window per
weekday**; the supplied array replaces the entire week and `[]` closes it.
It is always readable by Editor+ and editable under the same delegation flag.
The older Working hours dashboard now links to this reviewed editor.

Buffers are integers 0–240 (default zero). The effective after-buffer is the
larger of the owner setting and the existing event-type buffer. The daily cap
is null (unlimited) or 1–100. It counts confirmed Structure Cal bookings and
distinct pending moves on the effective host-local day, excluding the booking
being moved. Other Google/native events block time but do not count toward this
booking cap. Home city is null or up to 120 characters.

Weekly windows, used by `no_meetings` and per-type hour preferences, have this
shape (Monday=0, Sunday=6):

```json
[{"days":[0,1,2,3,4],"start":"07:00","end":"12:00"}]
```

Use at most 14 windows; `end` may be `24:00`. Split overnight windows across
weekdays. Restrictive windows cover both occurrences of a repeated DST hour.
Ambiguous/nonexistent working or preferred windows are not advertised as safe
slots. Rules use the host's effective travel timezone at the meeting start.

Trips:

- `GET /calendars/{id}/trips`: chronological `trips`, `complete: true`, host scope.
- `POST /calendars/{id}/trips`: `city`, IANA `timezone`, `starts_at`, `ends_at`.
- `PATCH /calendars/{id}/trips/{trip_id}`: current `If-Match` and all four fields.
- `DELETE` the same path: global current `If-Match`, preview then confirmed apply.

All writes above use the reviewed controls, global policy version and idempotency key described above.

Bounds are exact instants: start inclusive, end exclusive. Offset-aware ISO
values are recommended; local ISO date-times are interpreted in the supplied
trip timezone. Ambiguous/nonexistent local timestamps are refused; send an
explicit offset. Example: `{"city":"Example destination","timezone":"Asia/Hong_Kong","starts_at":"2030-10-17T00:00:00+08:00","ends_at":"2030-10-20T00:00:00+08:00"}`.
Trips may not overlap; maximum 200 stored trips, each at most 366 days. Remove
obsolete trips to free capacity. Version conflicts return 412; overlap returns
409 `trip_overlap`. No geolocation, travel-time inference, or flight integration
is performed. City comparisons ignore case, Unicode width and repeated spaces;
city aliases are not inferred.

During a trip, the owner's weekly working hours are interpreted in the trip's
timezone. Public slots retain guest-local display and expose `host_timezone`,
`host_city`, `preferred`, and `guest_late_hour`; the booking confirmation step
shows the host context. New/moved booking Google events use this effective host
zone while preserving exact instants. Existing event labels are not migrated.
For agenda default timezone on a date containing a transition, the host's home
local noon selects the trip context; use explicit `tz` for a different boundary.

Per event type:

- `GET /calendars/{id}/event-types`: owned types and their rule ETags.
- `GET/PATCH /calendars/{id}/event-types/{event_type_id}/booking-rules`.
- PATCH requires current `If-Match` and one or more of `meeting_mode`
  (`online` or `in_person`), `preferred_hours`, `request_only_hours`.

The default is online, including existing types after upgrade; owners should
mark physical meetings explicitly and set their home city. Online mode labels
the page; it does not create a meeting link or rewrite location text. In-person
slots are absent on dates away from the home city, and none are offered until a
home city is configured. The permalink remains usable to book a future return
home. Preferred hours rank first **within each guest-local day**. Request-only
hours disappear from immediate slots and enable the existing time-request
form, even if its earlier event-type checkbox was off. Requests still require
owner acceptance. Any part of a slot overlapping guest-local 21:00–08:00 triggers a friendly
warning after slot selection; this is advisory, not a refusal.

Public booking confirmation, slot suggestions and booking reschedules share
these rules. Availability overrides list additional exact check names:
`request_only_hours`, `no_meetings`, `daily_cap`, `in_person_location`. The existing
owner/delegated-override permissions, required reason and separate conflict
consent apply. Changing policy/travel after a reviewed plan requires a new
preview. These API preference writes record an audit entry. Nothing subscribes
a member, changes a live booking, or grants override permission automatically.

Missing/inaccessible calendar and booking reads return JSON 404 with respectively
`calendar_not_found` and `booking_not_found`. In booking reads,
`override_availability_allowed` is false whenever the booking cannot be moved,
including cancelled bookings; having an owner delegation flag alone is insufficient.

### Policy history and owner undo

`GET /calendars/{id}/booking-policy/history` is Owner-only, returns `history`,
`complete`, `next_before_id` and `policy_etag`. Follow `?before_id=...` for older
entries (50 per page). Entries cover all calendars currently owned by this host and include their
`calendar_id`, actor, action, time, compact `details.diff` before/after values, changed collections, `can_undo` and `undo_unavailable_reason`.
The dashboard displays those differences and offers **Preview undo**. Full policy
snapshots remain private and are returned only in applicable owner undo previews.

`POST /calendars/{id}/booking-policy/history/{audit_id}/undo` uses the same
preview/apply flow with the **current global** If-Match, no mutation fields,
and a new idempotency key. Undo restores the reviewed policy fields and deleted
trip identities only while the complete host state and revision still match the
audit's after-state. Any subsequent policy edit—even on another calendar—refuses
undo. The owner can then make a fresh reviewed edit. Undo itself advances the
policy revision and is audited. Owner snapshot-retention settings also expire
policy undo snapshots; compact actor/diff metadata remains and expired changes
cannot be undone. Delegation, events and notifications are not restored or changed.

## Ranked free slots

Editor+ can GET `/api/v1/calendars/{calendar_id}/free-slots` with offset-aware
`from` and `to` bounds (positive range, maximum 31 days), `duration` in minutes
(1–480; default 30), and optional `tz`, `guest_tz`, `prefer`, `limit`, and
`event_type_id`. `prefer` is `morning` (09–12), `afternoon` (12–17), or `evening`
(17–21), interpreted in the response timezone. `limit` is 1–100, default 20.
An event type must belong to the calendar owner and its duration must match.
Without an event type the search uses an online meeting with no type-specific
buffer or hour rules. Explicit `tz` controls display/ranking; otherwise the
owner's effective travel timezone at the range start is used. Each slot also
identifies its effective `host_timezone` and `host_city`, which may change across
the range. Every candidate includes guest-local timestamps and `guest_late_hour`.
`guest_tz` selects the guest zone; without it the display zone is the explicit
planning assumption. The response gives effective `guest_timezone` and
`guest_timezone_source` (`guest_tz` or `display_timezone`).

Example: `/api/v1/calendars/42/free-slots?duration=30&from=2030-01-07T00%3A00%3A00Z&to=2030-01-08T00%3A00%3A00Z&tz=Asia%2FBangkok&prefer=afternoon&guest_tz=Europe%2FLondon&limit=10`.

Google calendars must be verified as that owner's primary calendar. Native
calendars are supported, including hosts without Google. Searches include all
owned native calendars, confirmed Structure Cal bookings, pending reschedule
reservations, connected primary Google availability, and configured blocking
Google calendars. Other calendar events are not returned. Provider errors or
incomplete data fail the whole request. Host/type buffers, working windows,
travel zones, notice, horizon, increments, no-meeting periods, request-only
hours, in-person location rules and the booking cap are applied. The daily cap
counts confirmed Structure Cal bookings and distinct pending moves; external
events block time but do not count toward the cap.

Results include `slots`, `total_candidates`, `truncated`, `complete`,
`availability_checked: true`, `availability_complete: true`, applied buffer
minutes, `advisory: true`, and limitations. `complete: false` means only the top
`limit` ranked suggestions were returned; it never means a provider read was
silently partial. There is no cursor: narrow the range or increase the limit.
Each slot includes UTC, display-local and guest-local bounds, `guest_late_hour`,
`score`, and machine-readable `reasons`. A slot is late if any part of its
half-open duration overlaps guest-local **21:00–08:00**, matching public booking
hints. Ending exactly at 21:00 or starting at 08:00 is daytime; midnight and DST
are evaluated using actual instants. **All guest-day candidates rank before
all guest-night candidates**, regardless of host-preference score. Within each
tier, requested preferred period adds 100; event-type preferred hours add 50;
explicit guest-zone intervals wholly within 09–18 add 10; ties use earliest UTC
start. Score alone is not the sort order.

At most 20 owner calendars, 5,000 source events/intervals/bookings and 5,000
candidate starts are processed; larger searches fail explicitly. Google reads
all expanded event pages, applying the same explicit transparency and calendar
all-day default as other availability checks. No partial suggestions are returned.

Suggestions reserve nothing. Confirmation must recheck availability through the
booking workflow. GET and HEAD do not create bookings, operations or audits.

## Agenda briefing and travel defaults

Add `brief=true` to either `/api/v1/calendars/{id}/agenda` or
`/api/v1/agenda?calendar_ids=...`. Existing agenda permissions remain unchanged.
The ordinary top-level `events` list is preserved. `brief.events` excludes the
separate `brief.solo_reminders_07_09` group: timed events wholly within local
07:00–09:00 with a complete, empty attendee list. This is a display heuristic,
not a change to events, notifications or their busy status.

The `brief` object also contains `back_to_back_runs` (consecutive timed events
whose exact end/start instants touch), `overlaps` (simultaneously active event
references and overlap bounds), `free_afternoon_windows` (12:00–17:00 gaps),
`tomorrow`, and `first_meeting_tomorrow`. The latter is the earliest timed event
starting tomorrow, excluding the same morning solo-reminder heuristic; all-day
events are not treated as meetings. Each overlap reference includes `calendar_id`,
`id`, `title`, `start`, `end`, and `all_day`. Reference bounds describe the original
event; outer bounds describe the overlap clipped to the selected day. All-day
references retain literal dates and the exclusive end. Blocking all-day events
participate in overlaps and remove afternoon gaps; free all-day entries remain
visible as context. Explicit transparency and the calendar default follow the
all-day rules below.
DST comparisons use UTC instants, while returned times use the display timezone.
Both requested day and tomorrow are read completely; pagination failure,
malformed data or bounded read limits return an error, never a partial brief.

Explicit `tz` always wins. For a single calendar, an active trip on the requested
date supplies the owner's timezone even when the caller is an Editor with a
different profile timezone. With no owner trip, the existing caller-profile
timezone fallback is retained. A combined agenda uses the caller's active-trip
context; specify `tz` when combining calendars with different owners.

Briefing gaps describe only selected calendar events and do not apply booking
hours, notice, buffers or travel policy. Use `/free-slots` for booking suggestions.
`brief` accepts only the strings `true` or `false`. No calendar writes occur.

## All-day availability and explicit Free/Busy

Calendar setting `all_day_blocks_bookings` is a strict JSON boolean and defaults
to `false`. It belongs to the selected calendar registration. Existing all-day
events remain visible in event lists, agenda and morning briefs when they are
free for booking purposes.

Every availability path uses these rules, in this order:

1. Cancelled events are free.
2. An explicit returned `transparency: "transparent"` is free, regardless of the
   calendar default.
3. An explicit returned `transparency: "opaque"` is busy, regardless of the
   calendar default.
4. An omitted transparency on an all-day event follows
   `all_day_blocks_bookings`; an omitted transparency on a timed event is busy.

These rules apply to the individual `/busy` endpoint, combined availability,
legacy slot/booking checks, versioned booking reschedule checks, native calendar
blockers, and planning/brief calculations. Google all-day bounds use the Google
calendar's returned IANA timezone and the exclusive end date; daylight-saving
days may therefore last 23 or 25 hours.

Primary Google aliases are resolved using the connected account, not guessed
from GFAVIP email addresses. Booking checks use the primary calendar plus this
owner's Google calendars selected by `block_bookings`. Registered secondary
calendars have their own all-day default. An unregistered primary defaults to
false. Duplicate registrations of the same source use the more restrictive
all-day setting when combined in one booking/availability check. Other owners'
registrations do not affect a host's booking policy.

### Explicit per-event choices

Native and Google event create/PATCH accept `event.transparency` with the exact
values `opaque` or `transparent`; null, booleans and other strings are rejected.
The existing role, booking protection, ETag, recurring scope and explicit
`send_updates` requirements still apply. Changing a calendar default alone does
not modify Google events or send invitations.

For example, a versioned event PATCH may include:

```json
{"event":{"transparency":"opaque"},"send_updates":"none"}
```

Google documents `opaque` as its default and does not return a separate flag
showing whether a default was manually chosen. If Google omits transparency on
an event edited elsewhere, this app cannot distinguish an explicit external
Busy choice from an untouched default; it uses the calendar's all-day setting.
An explicit `opaque` value returned by Google always blocks. To preserve Busy
choices authored through Structure Cal even when Google omits the default
field, the app also writes a private `structureTransparency: "opaque"` marker.
Choosing `transparent` removes that marker. Explicit returned transparency
always wins over the marker, including later external changes to Available.
Unrelated private/shared provider properties are preserved; the marker itself
is not exposed in public event responses. Public responses show the effective
explicit transparency. Whole-series and future-scope writes use the same marker;
undo clears/restores it under the existing version guards. If an expanded all-day
occurrence omits both transparency and the marker, complete availability and
brief reads fetch and cache its validated master. An explicit occurrence choice
wins; unavailable or malformed master data fails closed. A read is limited to
100 master lookups across its sources.

Sources: [Google event resource](https://developers.google.com/workspace/calendar/api/v3/reference/events)
defines transparency and private extended properties;
[Google extended properties](https://developers.google.com/workspace/calendar/api/guides/extended-properties)
documents PATCH and null removal of individual properties.

### Complete reads and limits

Google FreeBusy cannot identify whether a block is all-day or carries explicit
transparency, so availability now uses bounded, complete `events.list` reads
with expanded occurrences. It follows all page tokens, including empty pages
that still have a next token. Missing/invalid pages, invalid event bounds or
timezones, malformed/repeated tokens, provider errors and free/busy-only access
fail closed. No failed or partial read is reported as free time. These reads
return interval data only from busy endpoints; they do not expose the event
details they internally inspect.

Each source is limited to 50 pages. Booking/planning checks share a 5,000-event
budget across at most 20 Google source calendars; oversized reads must use a
smaller range. Existing exact booking-event exclusion remains scoped to the
verified primary calendar. The same event ID on a secondary calendar remains a
conflict. No Google mutation is performed by any availability read.

## Guest reschedule requests and private booking prep

Guest manage pages now offer “Ask your host for a different time.” This creates a pending proposal and leaves the current booking and Google event unchanged. It does not reserve the proposed time, check availability, or send a notification. Existing immediate rescheduling remains available separately.

Guest bearer-link endpoints:

* `GET /api/bookings/{uid}/{guest_token}/reschedule-requests`: up to ten most recent requests for this exact booking/guest identity. No private prep or internal operation/actor fields.
* `POST` on the same path, JSON `{"start_utc":"2026-10-30T11:00:00+07:00","timezone":"Asia/Bangkok","reason":"Could we meet later?"}`. Requires the exact manage token, JSON, and same-origin browser writes. A proposal must be in the future and within 366 days; it inherits the booked duration. Off-hours/conflicting proposals can await review; submission makes no availability promise.
* One active request per booking; at most five created per booking in 24 hours. Pending requests expire seven days after creation. The proposal capability on the manage token expires seven days after the currently booked start. Reason limit 1,000 characters; request body limit 32 KiB. No guest endpoint changes or withdraws an approval in progress.

Team APIs require GFAVIP/session identity and Editor+ on the host’s primary Google calendar. Read/diagnostic access works while delegated booking rescheduling is off. Applying or declining requires owner access or enabled delegated booking rescheduling; availability override permission remains independently required by the existing engine.

* `GET /api/v1/calendars/{calendar_id}/booking-reschedule-requests?status=pending`: filter `pending|approving|approved|declined|expired|all`, 100 per page, `next_page_token`, `complete`. Numeric offset tokens max 10,000.
* `GET /api/v1/calendars/{calendar_id}/bookings/{booking_id}/reschedule-requests/{request_id}`: returns `request` and its `ETag`. Source booking version, proposed bounds/reason and approval operation ID are included for review. The proposal is immutable.
* `POST .../{request_id}/approve` with `If-Match: <request.etag>` and JSON `{"dry_run":true,"notify_guest":false}` previews through the existing reschedule engine. `override_availability`, `allow_conflict`, and operator `reason` may be supplied. Proposed bounds and source booking version come from the request, not caller replacements. If owner delegation is off, diagnostics return `can_apply:false` and no applicable plan hash.
* Apply on the same endpoint with the same reviewed request ETag, an `Idempotency-Key` (8–200 characters), and `{"confirm_reschedule":true,"plan_hash":"<preview.plan_hash>","notify_guest":false}` plus exactly the reviewed override/reason settings. Approval never bypasses the existing reschedule availability, booking-version, Google ETag, or explicit attendee-notification checks. `notify_guest:true` asks Google to update all current attendees through the existing workflow.
* Requests become `approving` before starting their durable reschedule operation, and `approved` only after the engine proves both Google and local booking completion. On uncertainty, retain the original actor/calendar/body/request ETag/key and retry. Changing the key cannot start a replacement approval. `request_state` may accompany errors with the latest request state/ETag. A proven preflight refusal before an engine operation is created reopens the proposal with a new ETag; obtain another preview.
* `POST .../{request_id}/decline`, current `If-Match`, JSON `{"reason":"Please keep the original time"}`. Only pending requests. Decline reason is guest-visible; no calendar move or email is sent.
* Owner recovery: use existing booking `reschedule-operations/{operation_id}/reconcile` or `/abandon` as appropriate first. Then `POST .../{request_id}/reconcile` with current request `If-Match` and `{"confirm":true}`. A proven complete operation marks the request approved. A durably abandoned operation, or a startup that never created an operation (checked under shared locks), reopens the proposal with a new ETag, or marks it expired. Active/uncertain operations cannot be released. This endpoint writes no Google event or notification.

Private prep:

* `GET /api/v1/calendars/{calendar_id}/bookings/{booking_id}/prep` returns `prep:{booking_id,notes,links,etag,updated_at,private:true}` and `ETag`. Missing prep is represented as empty notes/links with version-zero ETag.
* `PUT` same endpoint with current `If-Match` and complete JSON `{"notes":"Private briefing","links":[{"label":"Agenda","url":"https://example.com/agenda"}]}`.
* Editor+ can maintain prep independently of reschedule delegation. Notes max 10,000 characters; at most ten links, each label 1–200 and HTTP(S) URL 1–2,048 characters. Embedded credentials, control/space characters and other URL schemes are rejected. Links are stored as data and never fetched.
* Prep is a separate host-bound table. It never changes booking notes, guest custom fields, Google events, reminders or notification payloads, and is absent from guest/public manage/confirmation responses. Concurrent changes require a fresh ETag.

## Opt-in booking lifecycle webhooks

Booking lifecycle notices use the existing member-owned notification pipeline.
They require an explicit subscription on the host's verified primary Google
calendar, current Editor/Admin/Owner access, and an existing Google connection.
They do not require permission to reschedule bookings; they are read access.

Create with `POST /api/v1/calendars/{id}/subscriptions`:

```json
{
  "kind": "changes",
  "destination": "webhook",
  "url": "https://receiver.example.com/structure-calendar",
  "options": {"booking_lifecycle": true}
}
```

Use `destination: "feed"` and omit `url` for a pollable feed. This mode is
booking-only: it emits `booking.created`, `booking.rescheduled`, and
`booking.cancelled`; it does not copy general event bodies, historical audits,
Google external edits, agenda content, invitations, or private booking prep.
The server verifies and remembers the exact Google primary ID. It does not
assume that the GFAVIP email is the Google email. Secondary/native calendars,
Viewer roles, and email destinations do not support this mode.

Each notification contains `kind`, `source: "booking"`, `calendar_id`, `at`,
`subscription_id`, monotonic per-subscription `sequence`, a stable
`notification_id`, `provider_confirmed`, and a minimal `booking` object:
`id`, `status`, `start_utc`, `end_utc`, `google_event_id`. No guest name/email,
token, manage URL, notes, custom answers, reschedule reason, title, or preparation
material is included. IDs/times still represent private calendar metadata and
are delivered only to the subscriber's approved destination while access lasts.

Notices describe committed **local booking state**. For `booking.created`,
`provider_confirmed: true` means Google acknowledged insertion with an event ID;
creation does not independently read back the event's timing. For
`booking.rescheduled`, true means the durable calendar booking API verified the
Google timing before its local completion. Legacy reschedule and cancellation
notices use false because those helpers do not prove provider state. This field
does not prove guest delivery or continued provider consistency. A local
cancellation notice does not itself prove Google cancellation. New
subscriptions receive subsequent changes; there is no history backfill.

### Signing and delivery

Creation returns `signing_secret` once. Store it securely; it is encrypted at
rest alongside the destination. Requests include:

- `Idempotency-Key: <notification_id>`
- `X-Structure-Signature: t=<unix-seconds>,v1=<hex-HMAC-SHA256>`

Verify the HMAC over `timestamp + '.' + notification_id + '.' + raw_body` using
the returned secret, compare signatures in constant time, validate a reasonable
timestamp tolerance, and deduplicate stable notification IDs. Retries preserve
the body and ID but sign a fresh attempt timestamp. Different changes may arrive
out of order during retries: use `subscription_id` plus `sequence` if ordering
matters, and fetch current authorized booking state when necessary.

Outbox creation is idempotent for each committed lifecycle change. API reschedule
recovery uses its durable operation ID; a completed retry does not enqueue again.
Legacy hooks deduplicate the same final local booking version. Transport is
**at least once**, not exactly once: a receiver may accept a request just before
the connection fails. HTTP 2xx acknowledges transport only, not downstream work.

Timeouts/network failures, HTTP 408/429 and 5xx retry with exponential delay,
starting at 60 seconds and up to eight claimed attempts. A process crash can
consume an attempt. Expired leases permit recovery with the same ID; the eighth
expired attempt terminates without an unbounded ninth send. Other non-2xx
responses, including redirects, are terminal failures. No redirects are followed.
The current implementation does not provide manual redrive or a per-attempt
response archive; inspect the latest delivery state and correct configuration
before explicitly making a new subscription for future events.

Only public HTTPS port 443 destinations are permitted. All DNS answers must be
public; delivery repeats validation and pins the validated IP while preserving
TLS SNI and certificate verification. Private/reserved addresses, metadata hosts,
credentials in URLs, and mixed public/private DNS are refused. Subscription
ownership, Editor membership, connected provider access and primary binding are
rechecked before payload access or transport. Unsubscribe, downgrade, disconnect
or changed primary binding stops further delivery. An already-in-flight request
cannot be recalled; cancellation cannot be overwritten by its stale completion.

### Delivery log and configuration

`GET /api/v1/calendar-notifications/subscriptions/{subscription_id}/deliveries`
is private to that subscription's member with current access. It returns up to
100 latest-state records in sequence order, `next_cursor`, and `has_more`;
continue with `?after=<next_cursor>`. A record contains notification ID, sequence,
kind, status (`pending`, `sending`, `sent`, `dead`, `cancelled`, or `ready` for a
feed), attempt count, timestamps, next attempt, sanitized error category and
`acknowledged`. It excludes request bodies, endpoint URLs and signing secrets.
GET/HEAD never retries a delivery. The existing `/feed` endpoint exposes the
minimal booking notification body. Disabled or revoked subscriptions cannot
expose their private history through these endpoints; unsubscribe remains allowed.

This feature is off by default. Subscription creation/feed/logs and worker
processing require `CALENDAR_NOTIFICATIONS_ENABLED=1` plus a valid `FERNET_KEY`;
outbound webhook delivery also requires `CALENDAR_WEBHOOK_DELIVERY_ENABLED=1`
and the existing scheduled `notification_tick` worker. Google watch channels
are unnecessary for booking-only lifecycle notices. The helper itself never
sends HTTP, calls Google, creates a subscription, or commits. Existing explicit
opt-ins continue to capture durable outbox rows when worker flags are temporarily
off; transport resumes when enabled. No production flag/destination was changed
as part of implementation.

Missing or out-of-scope reschedule requests return404 with `code: "reschedule_request_not_found"`; missing or another member's notification subscription returns 404 with `code: "subscription_not_found"`, including deliveries.
