| index 3113245..04cd933 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -43,6 +43,10 @@ pass by reading a file the data has not reached. |
| pasted into.** The share page says so before the button, not after. |
| 4. **A compromised live host reads everything.** We never claim "we |
| cannot read your data." |
| +5. **A PUBLISHED booking page is a live free/busy oracle for that host, |
| + to anyone, without the database.** Every calendar the host can see |
| + shapes the answer. This is a NEW disclosure, not the one above, and |
| + the Publish button says so in a sentence before it is pressed. |
| |
| --- |
| |
| @@ -58,6 +62,27 @@ pass by reading a file the data has not reached. |
| | 6 | Delivery is at-least-once; every ambiguous case resolves towards sending | At-most-once — a reminder that never arrives is worse than one that arrives twice | This session, 2026-09-02 | |
| | 7 | Sign-in is a magic link, with no passkey second factor | Passkeys — `passkey.Config` derives its relying party from this instance's host, so one person in three team instances enrols three credentials. Same ruling docs made | This session, 2026-09-02 | |
| | 8 | A new event defaults to the person's PERSONAL calendar | Defaulting to the team calendar — the cost of forgetting to change a dropdown should fall on the side of privacy | This session, 2026-09-02 | |
| +| B1 | A booking is confirmed by a BUTTON on the page the mailed link opens — a same-origin POST — never on submit and never on GET | Instant confirmation; confirming on the GET, which every link-scanning mail gateway would press | Booking spec §1, 2026-09-07 | |
| +| B2 | Everything the host can see is busy — every calendar they hold a grant on | Personal only | Booking spec §1, 2026-09-07 | |
| +| B4 | The public identifier is a chosen plaintext `handle` plus a GENERATED page code | A chosen slug — prose joined to events | Booking spec §1, 2026-09-07 | |
| +| B5 | Booking mail is the ONE outbound to a non-team address, and only to an address the recipient typed in order to receive it | Emailing quick-add attendees "while we're at it" | Booking spec §1, 2026-09-07 | |
| +| B8 | A held slot is a tentative EVENT | A holds table the slot query consults | Booking spec §1, 2026-09-07 | |
| +| B11 | The EVENT's `sequence` is the one revision counter, bumped as `sequence + 1`; every booking-side write names the revision it expects | A booking sequence; a stale-read `+1` | Booking spec §1, 2026-09-07 | |
| +| B12 | Hold and reschedule serialise on a process mutex AND take SQLite's write lock before the final busy read, inside the transaction that inserts | The mutex alone; a busy read before `BEGIN` | Booking spec §1, 2026-09-07 | |
| +| B13 | Mail is a queue table, one row per (booking, kind, recipient, revision), claimed with an identity and a lease, acknowledged conditionally on that identity | A marker column | Booking spec §1, 2026-09-07 | |
| +| B14 | The management token is 32 random bytes, hex, stored SEALED as well as hashed | Hash only, as feeds do | Booking spec §1, 2026-09-07 | |
| +| B15 | A hold expires at the EARLIER of thirty minutes and the meeting's start; confirmation requires now < start | Thirty minutes flat | Booking spec §1, 2026-09-07 | |
| +| B16 | No sender error is "definite"; nothing is freed because a send failed | Marking holds failed | Booking spec §1, 2026-09-07 | |
| +| B17 | A slot is the same length on the wall clock and the elapsed clock, or it is not offered | Round-tripping the start only | Booking spec §1, 2026-09-07 | |
| +| B18 | A booked event's scheduling — start, end, tz, all_day, kind, rrule — belongs to the booking flow; the host's form shows them disabled and a save that changes any is refused | Invariant checks on host-supplied times | Booking spec §1, 2026-09-07 | |
| +| B19 | Mail says the CURRENT state; a transition deletes every unsent row the new state makes false | Historical snapshots per row | Booking spec §1, 2026-09-07 | |
| +| B20 | The booker sees the page's title and description AS THEY WERE at hold time, snapshotted sealed onto the booking | Live page text | Booking spec §1, 2026-09-07 | |
| +| B21 | Reschedule writes `tz` = the host's current zone atomically with both local strings and both UTC columns | Leaving `tz` as it was | Booking spec §1, 2026-09-07 | |
| +| B22 | Both queues carry a random `claim_id`; every completion mutation is conditional on it and built from the row the claim returned; reminders retry through `retry_at`, never `due_at`; `ErrMailBusy` defers without counting an attempt | Claim by timestamp; retry written into `due_at` | Booking spec §1, 2026-09-07 | |
| +| B23 | Availability FAILS CLOSED: a busy series that could not be expanded to completion, or whose rule this app cannot parse, is a 503, never "free" | Trusting `Expand`'s slice | Booking spec §1, 2026-09-07 | |
| +| B24 | Reschedule repairs the event's reminder queue rows INSIDE its transaction | After commit | Booking spec §1, 2026-09-07 | |
| +| B25 | A booked event's form carries its scheduling snapshot as hidden inputs, including the event's own `tz`, with the controls disabled | Disabled controls alone, which submit nothing | Booking spec §1, 2026-09-07 | |
| +| B26 | Reschedule enumerates the event's reminder RULES and upserts a queue row for each | Rebuilding the rows that exist | Booking spec §1, 2026-09-07 | |
| |
| Don't relitigate one in a drive-by. |
| |
| @@ -78,6 +103,10 @@ relying on remembering one, make it a test instead. |
| the pre-transition offset, the fold takes the second occurrence. Do |
| not reason about one from the other. |
| - **A day is 23, 24 or 25 hours.** Use `DayBounds`, never `Add(24h)`. |
| +- **`Zone()` takes bounded input on public routes:** `CommonZones` plus |
| + the host's own zone. Its cache holds successes only. |
| +- **A booking slot is the same length on the wall clock and the elapsed |
| + clock, or it is not offered.** Both ends round-trip through `ToUTC`. |
| - **Every range is half-open.** An inclusive end is how a one-day event |
| shows on two days and how a week query double-counts its boundary. |
| - **All-day events are DATES.** Never a datetime at UTC midnight, which |
| @@ -85,6 +114,11 @@ relying on remembering one, make it a test instead. |
| |
| **Recurrence** |
| - **COUNT counts GENERATED occurrences; EXDATE removes afterwards.** |
| +- **`Expand` may stop early.** Anything that decides FREE time reads |
| + `EventsInRangeStrict` and fails closed on `ErrIncompleteExpansion`. |
| +- **A day span is a difference of CIVIL dates, never elapsed hours over |
| + 24.** And advancement carries the series' ORIGINAL clock onto each |
| + new date — never the previous occurrence's resolved time. |
| - **A named date a month does not have is NO occurrence** — never |
| clamped. The 31st, 29 February, the fifth Friday. |
| - **Overrides are keyed by the occurrence's ORIGINAL local time and |
| @@ -98,6 +132,16 @@ relying on remembering one, make it a test instead. |
| - Every scoped read goes through a grant join. **A row that isn't yours |
| 404s, never 403s** — including for a write, so status codes cannot |
| enumerate the team's calendars. |
| +- **Booker name, address, note and TOKEN are sealed; the address has |
| + the keyed fingerprint. A page's public code is generated, never a |
| + chosen slug — a slug is prose. Booking windows are plaintext: |
| + shape-of-time.** |
| +- **Every booking transition is a conditional UPDATE naming the state |
| + and revision it expects, checking RowsAffected.** Zero rows is |
| + "someone moved it first", never a retry. |
| +- **A booked event's start, end, tz, all_day, kind and rrule belong to |
| + the booking flow.** A host edit that changes any of them is refused; |
| + the form shows them disabled with the stored values hidden beneath. |
| - **Never bind a request body onto a model.** Updates take an explicit |
| column allowlist; the columns absent from it (`calendar_id`, |
| `created_by`, `series_id`) are exactly the ones a hidden field must |
| @@ -108,8 +152,23 @@ relying on remembering one, make it a test instead. |
| Use `auth.From(r)`, and `requirePerson` for the id. |
| - Migrations are additive-only. Never edit one that has shipped. |
| |
| -**Reminders** |
| +**Ticks** |
| - **A `time.Ticker` is not a scheduler.** The instance hibernates. |
| +- **`NextDue` is the earliest CLAIMABLE instant** across reminders, hold |
| + expiries, owed booking mail and IP-forgets. New scheduled work goes |
| + here, never in a goroutine. Cheap work runs before delivery; |
| + reminders get the first half of the delivery budget. |
| +- **Every queue row has a random `claim_id`;** every completion |
| + mutation is conditional on it and checks RowsAffected, and a payload |
| + is built from the row the claim returned, never from the candidate |
| + read. A reminder's `due_at` is the occurrence minus the offset and is |
| + never written by a retry — `retry_at` is. Booking mail's `due_at` is |
| + retry eligibility only. |
| +- **No sender error is definite.** Never free a resource because a send |
| + "failed". Every send goes through `boundedSender`; `ErrMailBusy` is a |
| + deferral, not an attempt. |
| +- **A reschedule repairs the event's queue rows in the same transaction |
| + as the move.** |
| - **The tick route is mounted OUTSIDE `csrf.Protect`.** `csrf.Protect` |
| refuses any POST with no `Sec-Fetch-Site`, `Origin` or `Referer`, and |
| the scheduler sends none; inside the protected router a tick 403s |
| @@ -143,6 +202,8 @@ relying on remembering one, make it a test instead. |
| surface that names one says so, and their addresses are sealed with a |
| KEYED fingerprint for lookup — an unkeyed hash of an address is that |
| address in a different alphabet. |
| +- **Except a BOOKER, who typed their address to be mailed.** Booking |
| + mail is the one outbound to a non-team address. |
| |
| **Browser** |
| - `make browser` is the painted-output gate: Playwright, run from |