Skip to content

Add out of office and reassignment to Scheduling - #334

Merged
pumfleet merged 41 commits into
mainfrom
worktree-ooo
Nov 5, 2026
Merged

pumfleet merged 41 commits into
mainfrom
worktree-ooo

Conversation

@pumfleet

@pumfleet pumfleet commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Hosts can now be marked away, and bookings can move to someone else one at a time or in bulk.

  • Out of office: entries by time or whole date, for one host or many (hosts, host_filter), with preset or custom reasons, a public message, and external_id upserts for syncing an HR tool. Bookers can't book a host who's away, group joins included, and the reason never reaches them.
  • Delegates: an ordered list of who covers new bookings, inside the same event type and its routing.
  • Reassigning one booking: every kind moves, including group sessions, series, holds and requests awaiting approval. Candidates come back with a verdict, reasons and load. Overrides need a note and can never pass already_claimed.
  • Bulk runs: a preview that simulates the run, then per-booking outcomes, cancel and undo, and a host digest. A run never cancels a booking.
  • Holiday calendars: by country or ICS feed, materialized as ordinary entries. Deleting one tombstones it, so a host can opt out of a date.
  • Team page: a new manager session and hosted page (/t/[sessionId]), scoped by host list or attribute filter, plus a host portal "Time off" tab and an optional setting, off by default, for hosts reassigning their own bookings.
  • Surfaces: API, webhooks, SDK, MCP, CLI, dashboard, docs. iOS is not built.
  • Fixes this needed anyway: Zoom links lost when a collective's member was replaced, missed deletes on a host's old write target, manage page slots computed from the wrong hosts, and /host/bookings returning more than its comment said.

How it was tested

  • bun run verify is green on this commit (79 of 79 tasks). The scheduling package has 1,144 tests on real Postgres, including races for booking against entry, reassign against reassign, reschedule and cancel, and a run against a reschedule.
  • Contract tests cover the SDK, MCP and CLI against the in-process API.
  • Checked in a browser after round 2 only: dashboard Reassign through the team page, and moving an entry's bookings through preview and run. Later fixes to those pages have unit tests but weren't driven in a browser.

Not verified, or needs a person

  • Live calendars: A to B to A has only run against fakes. Graph's answer to a reused key and Google Meet's reused requestId still need a real account. The plan asked for this before merging; Bailey chose to merge and run it against production afterwards.
  • Legal: out of office reasons and notes are a new category of personal data, and the new retention entries change the published policy. Bailey confirmed legal is cleared.
  • Hostile feeds: a holiday feed is customer-supplied and uses the calendar reader's existing limits, so one rule can spend about 2.4 seconds of worker CPU, and one read about ten times that at most.
  • Translations: 16 new strings in 15 languages are machine-written and unreviewed. Counts avoid plurals, because the catalog has none.
  • Never driven in a browser: the part-day form on the team page, the host's "Give to someone" button, the new settings toggles, the embedded team page, and the reassignment_enabled checkbox.
  • Volume: run expansion past 5,000 bookings hasn't been tested with real data.
  • ICS feeds: tested with fakes only. Google's country feed URL is unofficial.

Behaviour worth knowing

  • /host/bookings returns less than it did. This is the unversioned client API that the hosted pages use.
  • Holiday regions: every holiday in a country applies unless regions narrows it.
  • The dashboard opens the team page rather than running availability itself (Bun-only rule), so moving bookings is its own step, not a checkbox when adding time off.
  • A digest run (notify.hosts: digest, the default) sends no calendar invitations or cancellations. each does, one per seat of a session.
  • memberStillHolds re-checks role and ban on each request, not SSO or 2FA requirements.
  • Custom reasons have no dashboard page yet.
  • This ships as one PR rather than the eight the plan sketched.

Review

27 rounds of Codex review. Round 27 reported nothing. No finding was rejected: all were real and fixed.

Merge of origin/main before review: reconciled per-host locations (a host's own office follows them on reassign,
including group sessions), /host/bookings keeps main's no_show_at and group_session, listRescheduleSlots
replaces listBookingSlots, migrations regenerated as one on top of main's.

Round 1: 9 findings, all real, all fixed

  • P1 assignment source unchecked: checkManagedRun also checks from_host_id (manager.ts).
  • P1 undo reached outside the team: undoReassignment takes onlyHosts; only moves between team members go back.
  • P1 host PATCH could start a run: host and manager sessions share SessionOutOfOfficeChange, without those fields.
  • P1 preferred hosts bypassed routing: filtered through candidates unless any_host, for bookings and sessions.
  • P2 empty ICS feed wiped entries: treated as a failed read when future holidays were known.
  • P2 calendar setting changes didn't reach entries: reconciled, only for entries older than the change.
  • P2 recurring all-day events lost their length: each occurrence lasts as long as the first.
  • P2 rescheduleBooking ignored reassignment_enabled when changing host: refused.
  • P2 truncated recurrence returned a partial list: now a failed read.

Note on round 1: the first version of the settings fix compared against updated_at, which every sync moves; its test
caught that, hence settings_changed_at.

Merge of origin/main after round 1 (#315, #317): no conflicts and no new migrations, but main's understoodRow
(notifications.ts) would have left reassigned, reassignment_summary and out_of_office_conflicts unclaimed for good.
8 tests failed on it; added the three kinds. Old workers leaving those rows alone during the rollout is what it's for.

Round 2: 4 findings, all real, all fixed

  • P2 a lock wait added to a hold: the remaining time is read after the target's locks, and re-checked (claimTarget's ttlOf).
  • P2 A to B to C lost A's removal email: a change only supersedes rows of hosts it touches, and those it cancels out hear nothing.
  • P2 changing delegates and starting a run in one PATCH used the old cover: delegates are saved first.
  • P2 the dashboard ran availability in Node, against the Bun-only rule: the dashboard opens the team page instead, as
    Reschedule opens the manage page (createDashboardSession, openTeamPageAction). The team page gained what the dialogs
    had. A first draft called the API from the dashboard's server, dropped unapplied: apps/web/AGENTS.md says it never
    calls the public API. Dashboard loses "move bookings" as a checkbox on adding time off: it's its own step there.
    Checked in a browser: dashboard Reassign -> team page -> booking moved, history says "Dashboard member"; Move bookings from
    an entry -> preview -> run (it stayed, since nobody was free, as the preview said). That found the query string being dropped
    with the token (client-api.ts), a picker left open after a move, and the away host offered as their own cover.

Round 3: 7 findings, all real, all fixed

  • P2 a holiday calendar changed or deleted during a feed read: checked again after reading, and a sync never overwrites a newer change's next_sync_at.
  • P2 any_host refused someone taken off the event type: it locks their row without needing it current, and never puts them back.
  • P2 "Stays" couldn't exclude a proposed move: a null choice is kept (chosenTarget).
  • P2 a preview's coverage came from unrouted candidates: worked out per booking, as the run does.
  • P2 a preview counted proposals from other event types against an event_type limit: only host limits count those.
  • P2 two overlapping PUTs with one external id: the loser tries again and updates.
  • P2 undoing an undo left a team's reach: managedRunWhere walks the chain.

Round 4: 3 findings, all real, all fixed

  • P1 a worker that lost its lease while waiting on a lock could move an item the next worker moved too: the item is taken in the move's transaction, compare-and-set on pending, and a move that loses rolls back.
  • P2 a PUT sending an entry to another host didn't start a run for the new host's bookings: a host change counts.
  • P2 a holiday feed with a dateless event read as a good feed and took holidays away: it's a failed read.

Round 5: 4 findings, all real, all fixed

  • P1 a session moving an entry that already had existing_bookings: reassign started its run, reaching hosts past the session's scope: session edits pass startsNoRun, on team and host pages alike.
  • P2 a run moved a booking rescheduled out of its selection: selection's conditions are asked again with the booking locked (stillSelected), reported as skipped_changed.
  • P2 a queued run wrote its note about someone erased since: a move doesn't write a note on a booking whose attendee is erased, read with a share lock in erasure's order.
  • P2 undoing part of a collective change dropped the attendee's notice: it's only gone if no other pending host notice is left.

Round 6 (the first run was killed, exit 144, and wrote nothing; retried): 5 findings, all real, all fixed

  • P2 undoing a session whose first seat cancelled did nothing: another live seat stands for it (liveSeats).
  • P2 a host in a pool staffed by a filter had nobody to name as cover: listPossibleDelegates asks who takes each event type, as routing does.
  • P2 a team page asked to override outside_coverage, which the API refuses: that's any_host (overridesFor).
  • P2 a request whose time to approve had run out counted as a conflict: liveBooking checks it.
  • P2 reassignment_enabled wasn't in the dashboard's event type form: it is now.

Round 7: 3 findings, all real, all fixed

  • P2 a holiday calendar's cover who'd been deleted stopped every sync: they're left out.
  • P2 a group seat with no claim left moved, with a claim that never ended: it's hold_expired.
  • P2 changing a calendar's write target left the block on the old one, with old times: it goes.

Round 8: 2 findings, all real, all fixed. Merged origin/main (fairer pools, slot diagnostics, calendar delegation)

  • P2 a run cancelled while being worked out got pending items for good: expansion takes the run's lock, as cancelling does.
  • P2 a far-off booking made a preview call a host's calendar unverified for near ones: each is judged to its own end.
  • Merge: 23 conflicts, additive apart from rankCandidates (main's lowestShare and our atLeastRatio). Migration regenerated on top of main's.
  • Merge, found reading it: explainSlot didn't know about cover or joining during time away, so it disagreed with booking; assignFairly now gives back a share kept on the host row, so a booking changing hands does that with the row still there.

Round 9: 4 findings (3 P2, 1 P3), all real, all fixed

  • P2 create_scheduling_out_of_office said it reached nobody, and it can email hosts and attendees: SENDS, and the description says so.
  • P2 a team page could only override the first thing in someone's way: candidates list reasons, all of them, and the page asks for each.
  • P2 a preview proposed a disabled host, whom the run refuses: it says host_unavailable.
  • P3 from_host by uid missed whose meeting it was: resolved to an id first.

Round 10: 3 findings, all real, all fixed

  • P2 a holiday sync that outlasted its lease overwrote a newer one: the lease is checked before reconciling and before finishing.
  • P2 deleting a calendar left a holiday still running west of UTC: an entry's end decides, not UTC's date. One that's started isn't "gone from the feed", since feeds are read from today.
  • P2 a preview treated a host someone had just been moved off as still busy: it tracks what's freed, in claims and limits.

The gate after round 10 caught a flaky test of that round's, which came from a real disagreement: for bookings at one time a preview went by booking id and the run by item id. The run now goes by booking id too.

Round 11: 3 findings, all real, all fixed

  • P2 the dashboard couldn't make an ICS holiday feed, having nothing to encrypt its address with (only a country was ever tried in a browser): it passes schedulingCalendarDeps().
  • P2 a host who lost a booking heard nothing if it was cancelled before the email went: they still do (lostItBeforeItEnded).
  • P2 a team page only ever showed the first 100 entries of time away: after, limit and Show more. Bookings say when they're only the soonest 100; moving a host's covers the rest.

Round 12: 2 findings, all real, all fixed. Merged origin/main (attendee limits, Risk pricing); migration regenerated on top of main's

  • P2 a booking waiting on a delegate's lock could land after their cover was taken back: changing cover also takes the delegates' rows, and a booking reads it again with theirs locked (coverAsItStands). What's left: a delegate covering two people at once, where one's outside the booking's routing and the other's cover goes while it waits, still gets it.
  • P2 a preview went one event type at a time, where a run goes by time, so it could use up a shared limit differently: it goes in the run's order.

Round 13: 2 findings, all real, all fixed. Merged origin/main (event type descriptions, booking history, cancellation reasons); migration regenerated

  • P2 a long list of regions was cut at a name's length, so Quebec on Canada's Boxing Day didn't match: regions have their own limit.
  • P2 re-reading cover counted a delegate's cover of someone unrelated: it keeps to the people it was found through (the leftover noted in round 12).
  • Merge: manager_session added to the booking history's actor kinds, since a team page's moves appear there. Its details hold reason_code and host ids, and never the note.

The gate after round 13: main's cancellation reasons and this branch's routing both re-encrypt booking rows, and two passes over them broke main's test of erasing between read and write. One pass, one compare-and-set.

Round 14: 3 findings, all real, all fixed. Merged origin/main (localized notifications); migration regenerated

  • P2 a host's session could read why a colleague couldn't take a booking, from the refusal: namesOnly.
  • P2 a session's new host heard nothing if the seat carrying the news cancelled: another seat carries it (seatStillStanding).
  • P2 a preview only counted fairness up, where a run also takes it off whoever a booking leaves: it does both, with last-assigned as well.
  • Merge: this branch's emails join the catalog in all 16 languages. Counts read "Bookings that came to you: 2" since the catalog has no plurals. They're my translations, unreviewed.

Round 15: 3 findings, all real, all fixed. Merged origin/main (hosted MCP, usage guardrails); migration regenerated

  • P2 deleting an entry only checked whose it was before waiting for the host's lock: it asks again once it has it.
  • P2 a first read of a feed missed a holiday still running west of UTC: feeds are read from the day before, and a host's own date decides.
  • P2 a PATCH with a new reassign target was ignored unless existing_bookings was repeated.
  • Found on the way: an edit in round 13 had also deleted the tests of round 10's holiday fixes. Restored, and they pass.

Round 16: 2 findings, all real, all fixed. Merged origin/main (delegation's plan). (The migration needed nothing this time.)

  • P2 an entry deleted before its run got there still moved its bookings: a deleted entry selects nothing.
  • P2 a PATCH of only time_zone was accepted and ignored: on whole dates it applies, on times it's refused.

Round 17: 3 findings, all P1, all real, all fixed. Merged origin/main (plans and fees for Scheduling); migration regenerated

  • P1 a manager session's response kept its link, with the token in it, in request logs: it's a capability response.
  • P1 out of office, reassignments and holiday calendars kept their bodies in request logs: reasons, notes, and feed URLs that can hold tokens. They're excluded like bookings, with tests of each path.
  • P1 a scheduling:create key could move bookings by creating time away or a holiday calendar with existing_bookings: reassign: that now needs scheduling:update (apiKeyHasPermissions, as broadcasts do).
  • Merge: /slots keeps main's new rate limit. API-minted manager sessions go by the plan's host portal feature, as a host's page does.

Round 18: 3 findings, all real, all fixed

  • P2 reading attendees for a note before locking the booking was the opposite of erasing's order, and could deadlock it (my round 5 fix assumed the wrong order): it's read after the booking's lock.
  • P2 a PUT that waited on a lock while its entry was deleted updated the deleted row and reported success: it reads again once it has the lock, and makes a new one.
  • P2 a run started by time away silently stopped at 5,000 bookings: the cap is on what's asked for, and expanding takes it all. Not tested with 5,001 real bookings.

Round 19: 3 findings, all real, all fixed

  • P2 a host who lost a booking that was then cancelled heard, but with no CANCEL attachment: the invitation stayed on their calendar.
  • P2 retrying a run with its Idempotency-Key failed if what it named had been deleted since: a replay comes before anything that can have changed.
  • P2 a run's report was purged 395 days after it was made, though its bookings can outlive that: it waits for their rows.

Round 20: 2 findings (1 P1, 1 P2), all real, all fixed

  • P1 a dashboard team page kept reaching every host for up to an hour after its member lost access: the API asks on every /manager/* request (memberStillHolds), and refuses as it does any session that's over. By role only: it doesn't re-apply the organization's SSO and two-factor requirements.
  • P2 an update that waited on a host's lock could change an entry that had moved to another host without holding theirs: it reads again, and locks them.

Round 21: 2 findings (1 P1, 1 P2), all real, all fixed. Merged origin/main (reminders)

  • P1: a run started by an entry kept moving the first host's bookings after the entry was given to another host. selectionConditions now compares the entry's host with the selection's and selects nothing when they differ. Test: entry moved from A to B before the worker ran leaves A's booking with A.
  • P2: moving a group session sent the former host one cancellation, but each seat's invitation has its own UID, so the rest stayed on their calendar. The former host now gets a CANCEL per seat; the new host still hears once. A digest run (notify.hosts: digest) carries no cancellations, each does (documented in packages/scheduling/AGENTS.md). Test: three seats give three "No longer yours" with three distinct UIDs.

Round 22: 2 findings (2 P2), all real, all fixed

  • P2: former-host cancellation used another seat's UID when its own seat cancelled before notifications ran (seatStillStanding substituted it). Who lost a seat is now told about that seat; substitution is only for the new host. Test: two seats, one cancels, two CANCELs with each seat's UID.
  • P2: the empty-feed guard compared with today's UTC date while the read starts a day earlier, so a holiday still running west of UTC could be removed by an empty read. The guard now uses earliestDay(now). Test: Los Angeles host, feed emptied at 01:00 UTC the next day, entry kept.

Round 23: 2 findings (2 P2), all real, all fixed

  • P2: bulk digests read only the environment policy, ignoring event-type notifications.reassigned.email_host both ways. hostsToldOfRun and reassignmentCountsForHost now resolve the policy per event type, and silenced bookings are not counted. Tests: silenced by the event type, enabled by it against the environment, and a mixed run counts 1.
  • P2: repeated unchanged PUT upserts queued a new conflicts email each time because the dedupe key used updatedAt. Unchanged host, times and existing_bookings now queue nothing. Test: three identical PUTs give one notice, a changed end gives a second.

Round 24: 3 findings (3 P2), all real, all fixed

  • P2: updateOutOfOffice read cover before waiting for the host lock and only re-read it if the host changed, so a booking with a newly named delegate could commit on cover the edit was removing. lockEntrySettled now re-reads after each wait until nothing is missing; putOutOfOffice and deleteOutOfOffice use it too. Test: edit waits for the host, cover is replaced, edit must then wait for the new delegate.
  • P2: group booking retried through drizzle nested transaction, which rolls back without releasing, against packages/scheduling/AGENTS.md. Now inSavepoint. No behaviour test: it only changes what Postgres retains.
  • P2: meeting_provider_missing counted a connected calendar even when it was not a write target, though setCalendarState writes only to those. Now requires a write target or the Zoom calendar. Test: simulated calendar with its write target turned off reports true.

Round 25: 2 findings (1 P1, 1 P2), all real, all fixed

  • P1: memberStillHolds checked only the role, so a banned member kept a working dashboard manager session until it expired. It now checks the ban and its expiry. Test: banned and banned-until-later give 404, an expired ban gives 200.
  • P2: holiday reconciliation told stale entries from edited ones by comparing updatedAt with settingsChangedAt, so an entry written from old settings just after a change kept stale cover permanently. Replaced with settings_revision on the calendar and source_revision on entries; an edit is stamped with the revision it was made against. Tests: an entry newer than the change but from an older revision catches up; an edit after the change stays. Unshipped migration regenerated.

Round 26: 2 findings (2 P2), all real, all fixed

  • P2: a session's new host had one invitation, from one seat, so that seat cancelling removed it while the session continued. The new host now gets one per seat like the former host and any group host, and seatStillStanding is removed. Test: two seats, one cancels after delivery, only its UID is cancelled.
  • P2: the team page kept the host who had just left as the source, so a second move failed. sourceHost derives it from the booking's hosts. Test: unit test of the helper; not driven in a browser.

Round 27: nothing reported. Clean.

After the PR opened: CI

  • test failed on a 5 second timeout in "a rule that goes on forever can't take the worker with it". The test spends a rule's whole cycle allowance on purpose (2.4 s locally). Test-only change: a 30 second timeout. No behaviour changed, so no further review round.

Merge of origin/main before merging (expo-kloq, 17 commits)

Clean, and touches none of this branch's code, so no further review round. Gate green on the merge commit.

…change

Replacing a host reset the meeting link even when the meeting was someone
else's: Zoom never gives a link back on update, so a collective booking lost
it for good, and a new host who sorted first started a second meeting.
Meetings now stay with whoever runs them, and only reset when that host
leaves.

A booking going to another host and back reused a deleted event's key, and
Graph's answer to a repeated transactionId is undocumented, so events now get
a new key each time they're deleted. Removing an event goes by the ledger,
so an event on a calendar that's stopped being the write target still goes.
Co-hosts' events now get a Meet link made by someone else's event.

Slots for moving a booking come from the hosts it has, not who the event type
would pick today.
Choosing another host for a booking has to keep to what it was routed with,
and that was dropped once a host had won. It's now kept with the booking,
encrypted, and goes when the attendee's details do. An empty value means
nothing narrowed it, and null means it can't be known, so a later choice never
mistakes one for the other.
Hosts can be marked as away, for part of a day or whole dates, one at a time,
as a list or by attributes. They aren't offered during it, joining a running
group session included, and a pool passes over them. Existing bookings are
never touched: they're listed as conflicts, and the host's told how many.

Bookers see when the host's back and any message they left. Reasons can say
someone's on medical leave, so they stay out of webhooks, logs, calendars and
anything a booker sees, and notes are encrypted. An entry puts a private event
on the host's calendar, through the same ledger as bookings, which is why
deleting one is soft: the row that removes the event belongs to it.

put by your own id lets an HR tool sync without keeping ours, and webhooks say
when time away is created, changed, starts and ends. Hosts get a Time off tab,
and the dashboard an Out of office page.
Any kind of booking can now go to another host, keeping its id and time: a
one-on-one, a pool, either role of a collective, a whole group session from any
of its seats, a hold, a request waiting for approval, and for the record one
that's already started.

The target is claimed before the source lets go, so a lost race leaves the
booking as it was, and nothing can override a host who already has something at
that time. Their hours, calendar, limits and time off can be gone past, with a
note saying why, which is encrypted and goes when the attendee's details do.
auto picks the fairest who's free and keeps to what the booking was routed with.
Candidates say who could take it, and why not.

Whoever's leaving and joining are told apart, the attendee gets an update with
the same invitation, and going to someone and straight back says nothing stale.
rescheduleBooking's host goes through the same notices, webhook and fairness.
The dashboard's booking page gets a Reassign dialog, with MCP tools and CLI
commands for this and out of office.
A bulk reassignment moves up to 5,000 bookings in the background, each in its own
transaction, so one that can't move never holds up the rest. Nothing's ever
cancelled: a booking nobody can take stays where it is, and its item says so.

Choose by host, out of office entry, ids or series, narrowed by time, event type
or calendar conflict. Send them all to one host, try some hosts first and then the
fairest who's free or leave the rest, or say who each goes to. A preview shows who
each would go to without moving anything, working down them in order so two can't
be promised the same free hour, and a change to it goes back as assignments.

A run can be cancelled, and undone as a new run that skips anything that's moved
on since. Each move's outcome commits with the move. Hosts get one email per run
and a scheduling.reassignment.completed webhook carries ids and counts. Out of
office entries can start a run with existing_bookings: reassign.
Out of office entries can name delegates, in order, who can be booked in the host's
place during that time, inside the same event type. They're candidates only for a
meeting that touches the time away, decided in isBookable so slots and booking can't
disagree, and they still have to fit how a booking's routed: a pool's filter, what
narrowed it, and not having been taken out by name. It doesn't chain, and required
hosts just block.

A delegate ranks no better than the person they're covering, or their own low count
would make them take everything. A group now picks its host by time. A bulk run from
an entry tries its delegates first.

When someone comes back, their count in each pool moves up to the lowest of their
peers, once, so they don't take nearly every booking until they've caught up.

A host can name their own cover from their page, from people they share an event
type with, and sees only names.
A manager session gives a team lead a hosted, embeddable page for the hosts a
customer names, or everyone matching some attributes whenever it's asked: who's
away, what needs cover, and moving it, one at a time or many at once. There's no
team table: who manages whom is the customer's to say when they mint one.

Its reach is checked before anything's read and again with the booking locked, and
someone else's looks like it doesn't exist. It sees times, event types and hosts,
never attendees' details. A run from it says who each goes to, since the fairest
who's free could be on another team, and the worker has no session to ask.

Hosts can give their own bookings to someone free who takes that event type, once
the environment turns on host_reassignment. They see names only and can't go past
anyone's availability.

/host/bookings no longer returns contact details, metadata or other hosts.
A country's public holidays, or a customer's own ICS feed, applied to some hosts: by
name, or everyone matching some attributes at each sync. A worker turns each into
ordinary out of office entries, one per host and day in that host's zone, so
everything that knows about being away already knows about these. Delegates and
what happens to existing bookings come from the calendar.

A deleted entry stays as a tombstone, so a host who opts out of a date isn't given
it back. A date that's gone from the feed, or a host who's left, isn't one: nobody
chose that.

A failed, partial or empty read keeps what's there. A feed's URL can hold a token, so
it's encrypted with WEBHOOK_SECRETS_KEY, goes through the pinned transport, never
follows a redirect and never appears in a log or response, and sandboxes don't read
them. Holiday names are unscreened, so they reach no email, entry or booker.

Countries use Google's public ICS feeds, which need no key. Those mark every holiday
as free, so they can't go through the check for busy time. Regional holidays apply
unless `regions` narrows them.
Main gave hosts their own locations, guests on bookings, details on calendars and
hosts moving their own bookings, so this reconciles them with reassignment:

- A host's own office or link follows them when a booking or a whole session goes to
  someone else: the new host's takes its place, or it's left blank, and it never keeps
  the old host's address. A meeting on someone else's account still stands.
- Booking through a delegate uses the delegate's own location.
- /host/bookings keeps the no-show and session fields main's page reads.
- Main's listRescheduleSlots replaces listBookingSlots, which answered the same question.
- This branch's migrations are regenerated as one on top of main's: none had been
  applied anywhere but a local database.
- New operations are documented for agents and listed in the agent surface.
Team pages and hosts:
- An assignment's source has to be on the team, as its target does.
- Undoing from a team page only gives back moves between its own people.
- A host's or manager's session can't start moving bookings by changing an entry.

Routing:
- A preferred host still has to fit how a booking was routed, unless `any_host` says
  otherwise, for bookings and whole sessions.
- rescheduleBooking can't change the host of an event type with reassignment off.

Holidays:
- A feed that's suddenly empty, or too long to expand, is a failed read and keeps
  what's there.
- A recurring closure lasts as long as its first occurrence.
- A change to a calendar's cover or bookings policy reaches entries already made,
  without undoing a host's own change since (settings_changed_at, since every sync
  moves updated_at).

Also makes a Zoom test deterministic: it assumed which of two ids from one millisecond
sorted first.
Main's understoodRow only claims the notification kinds it lists, so these three would have waited for good.
The dashboard ran availability checks on Node, against the rule that time is only
worked out in Bun. It now opens the team page for them, the way Reschedule opens the
manage page: reassigning, its candidates, a run's preview and out of office's dates.
- createDashboardSession: a manager session for a member, reaching every host of the
  environment for an hour, acting as them. `{}` means that only with a member behind
  it, and nothing else can mint it. Opening it is in the audit log.
- The team page gained what the dashboard's dialogs had: part days, several covers, a
  message, notes, the calendar toggle, reasons, notes and telling attendees on moves,
  and "starting before". It takes a booking or an entry to start from.
- A preview from a team page no longer lists every host as preferred, which failed
  past 20 of them.
- A test refuses the functions that compute time in the dashboard's code.
- Clearing the token from the address bar keeps the query string.

Holds: a hold's remaining time is read after the target's locks, so waiting for them
comes out of it, and one that ran out meanwhile can't move.

Notifications: a change only supersedes what's pending for hosts it touches. Going A
to B to C tells A and C and not B. Going back to A tells nobody.

Out of office: a PATCH that changes cover and starts a run uses the new cover.
- A holiday calendar changed or deleted while its feed is being read: the sync checks
  again before making anything, and never overwrites the next_sync_at a change asked for.
  Deleting now counts as a settings change.
- any_host can name someone taken off the event type, without putting them back on it.
- "Stays" in a run's preview takes a proposed booking out of it (chosenTarget).
- A preview works out whose time someone's covering per booking, from who it could go to,
  as the run does.
- A preview only counts other event types' proposals against a host limit, not an
  event type's.
- Two overlapping PUTs with one external id: the second tries again and updates.
- Undoing an undo stays in a team's reach: managedRunWhere walks back to the run that
  started it.
- A worker that lost its lease while waiting on a lock could move an item the next worker
  also moved. Taking the item is now a compare-and-set on pending in the move's own
  transaction, and a move that loses rolls back, so an item moves once. A run cancelled
  mid-move now keeps that booking where it was, too.
- A PUT sending an entry to another host starts a run for that host's bookings.
- A holiday feed with an event missing its date is a failed read, rather than a good feed
  that takes holidays away.
- A team or host session moving an entry made with existing_bookings: reassign started its
  run, which could send bookings past the session's reach. Session edits start no run
  (startsNoRun): what's now in the way is reported, and moving it is its own step.
- A run moved a booking rescheduled out of its selection. The selection's conditions are
  asked again with the booking locked, and it's reported as skipped_changed.
- A queued run wrote its note about someone erased since. A move writes no note on a booking
  whose attendee is erased, read with a share lock in the order erasing takes them.
- Undoing part of a collective change dropped the attendee's notice. It only goes if no other
  host is still waiting to hear of the change.

Also fixes a deadlock race test that passed the wrong parameter, so its reschedules were always
refused and it wasn't racing anything. It now does, and still doesn't deadlock.
- Undoing a group session whose first seat cancelled did nothing: another live seat stands
  for the session.
- A host in a pool staffed by a filter had nobody to name as cover: listPossibleDelegates asks
  who takes each event type, as routing does, and someone taken off by name stays off.
- A team page asked to override outside_coverage, which the API refuses: that's any_host.
- A request whose time to approve has run out no longer counts as a conflict.
- reassignment_enabled is in the dashboard's event type form.
- A holiday calendar's cover who'd since been deleted stopped every sync: they're left out.
- A group seat with no claim left could move, and get a claim that never ended: it's
  hold_expired.
- Changing a calendar's write target left the out of office block on the old one, with the
  old times: it goes.
Brings in fairer pools, slot diagnostics and calendar delegation. Conflicts were additive
apart from rankCandidates, which keeps main's lowestShare and this branch's atLeastRatio. This
branch's migration is regenerated on top of main's, as 0130.

Fixes from reading the merge:
- explainSlot goes by what booking checks: someone covering is only free while they are, and
  a running session can't be joined while its host is away.
- A booking changing hands gives back the share it added, now kept on the host's row, so that
  happens with the row still there, for reassigning and for rescheduling to another host.

Round 8:
- A run cancelled while it's being worked out got pending items for good: expanding takes the
  run's lock, as cancelling does.
- A far-off booking made a preview call a host's calendar unverified for nearer ones: each
  booking is judged to its own end (calendarVerifiedUntil).
- create_scheduling_out_of_office is marked SENDS, and says who it emails: it can reach hosts
  and attendees.
- Candidates list everything in someone's way (reasons), so a team page can ask for every
  override it needs, rather than only finding out about the next one when the first fails.
- A preview doesn't propose someone disabled, whom the run would refuse.
- Candidates' from_host works by uid as well as id when deciding whose meeting it is.
- A holiday sync that outlasted its lease overwrote a newer one: the lease is checked before
  reconciling and before finishing.
- Deleting a holiday calendar left a holiday still running west of UTC: an entry's end decides,
  not UTC's date. One that's started isn't gone from a feed, since feeds are read from today.
- A preview treated a host someone had just been moved off as still busy: it tracks what's
  freed, in claims and limits, as the run would find them by then.
- The dashboard couldn't make an ICS holiday feed, having nothing to encrypt its address with.
- A host who lost a booking heard nothing if it was cancelled before the email went. They do
  now, since a cancellation only reaches the hosts it has by then.
- A team page only ever showed the first 100 entries of time away: it pages through them now.
  Bookings say when they're only the soonest 100.

Also from the last gate: for bookings at one time, a preview went by booking id and a run by
item id, so a run could disagree with its preview. A run goes by booking id too, which also
makes that round's test stable.
Brings in attendee limits and Risk's pricing. This branch's migration is regenerated on top of
main's, as 0131.

- A booking waiting on a delegate's lock could land after their cover was taken back. Changing
  cover also takes the delegates' rows, and a booking reads it again with theirs locked.
- A preview went one event type at a time where a run goes by time, so it could use up a shared
  limit differently: it goes in the run's order.
Brings in event type descriptions, booking history and cancellation reasons. This branch's
migration is regenerated on top of main's, as 0134. A team page's moves appear in the new booking
history, so its actor kinds gain manager_session. Erasing and redacting clear both a cancellation's
reason and what a booking was routed with.

- A long list of regions was cut at a name's length, so Quebec on Canada's Boxing Day didn't match.
- Re-reading cover with a delegate locked counted their cover of someone unrelated: it keeps to the
  people it was found through.

Also fixes round 4's dateless-event test, which wrapped its event in a second VEVENT.
One pass per booking, with both columns in one compare-and-set, rather than two passes over the same rows.
Brings in localized notifications. This branch's emails join the catalog in all 16 languages, with
counts as "Bookings that came to you: 2", since it has no plurals. Its migration is regenerated on
top of main's, as 0135.

- A host's session could read why a colleague couldn't take a booking, from the refusal.
- A session's new host heard nothing if the seat carrying the news cancelled: another seat does.
- A preview only counted fairness up, where a run also takes it off whoever a booking leaves.
Brings in the hosted MCP server and usage guardrails. This branch's migration is regenerated on top
of main's, as 0136.

- Deleting an entry only checked whose it was before waiting for the host's lock: it asks again once
  it has it, so a session can't delete what's just become someone else's.
- A first read of a holiday feed missed a holiday still running west of UTC: feeds are read from the
  day before, and a host's own date decides.
- A PATCH with a new reassign target was ignored unless existing_bookings was repeated.

Also restores three holiday tests an edit in round 13 deleted by mistake.
Brings in delegation's plan restriction.

- An entry deleted before its run got there still moved its bookings: a deleted entry selects
  nothing.
- A PATCH of only time_zone was accepted and ignored: on whole dates it applies, on times it's
  refused.
Brings in plans and fees for Scheduling. API-minted team pages go by the plan's host portal feature,
as a host's page does, and /slots keeps main's rate limit. This branch's migration is regenerated on
top of main's, as 0137.

Privacy and permissions:
- A manager session's response kept its link, with the token in it, in request logs.
- Out of office, reassignments and holiday calendars kept their bodies in request logs: reasons,
  notes, and feed URLs that can hold tokens. They're excluded as bookings are, with a test per path.
- A scheduling:create key could move bookings by creating time away or a holiday calendar with
  existing_bookings: reassign. That now needs scheduling:update.
- Reading attendees for a note before locking the booking was the opposite of erasing's order, and
  could deadlock it: it's read after the booking's lock.
- A PUT that waited on a lock while its entry was deleted updated the deleted row and reported
  success: it reads again once it has the lock, and makes a new one.
- A run started by time away silently stopped at 5,000 bookings: the cap is on what's asked for, and
  expanding takes it all.
- A host who lost a booking that was then cancelled heard, but with no CANCEL attachment, so the
  invitation stayed on their calendar.
- Retrying a run with its Idempotency-Key failed if what it named had been deleted since: a replay
  comes before anything that can have changed.
- A run's report was purged 395 days after it was made, though its bookings can outlive that: it
  waits for their rows.
- A dashboard team page kept reaching every host for up to an hour after its member lost access. The
  API asks on every /manager/* request whether they still can (memberStillHolds), and refuses as it
  does any session that's over.
- An update that waited on a host's lock could change an entry that had moved to another host
  without holding theirs: it reads again, and locks them.
Brings in main's reminders work (#329): notifications now take their
policy from notification-policy.ts, their copy from reminder-copy.ts and
recordChange from booking-record.ts; the `reassigned` kind joins each.
The branch's migration is regenerated on top of main's, as 0139.

- A run an entry started no longer moves the first host's bookings after
  the entry was given to someone else.
- A group session's former host gets a calendar cancellation for every
  seat, since each seat's invitation has a UID of its own.
- A former host's news about a session belongs to one seat. A seat
  cancelling first no longer swaps in another seat's booking, which sent
  two cancellations for the seat still standing and none for the one that
  held the invitation. A new host still hears through a seat that stands.
- A holiday feed that turns up empty is refused from the same day the read
  starts, so a holiday still running west of UTC isn't taken away.
- A run's digest asks each booking's event type whether its hosts hear, as
  a booking moved by itself does, in both directions: an event type can
  silence it, and can turn it on against the environment's choice. What's
  silenced isn't counted either.
- Saying the same thing again about an entry (an HR sync's PUT, say) no
  longer emails the host again about its conflicts. A change of host,
  times or `existing_bookings` still does.
- Writes to an entry hold whoever's covering as they are once the write
  has waited for its host, not as they were read before. A booking with a
  delegate holds only that delegate, so missing one let it commit on cover
  that had gone. Edits, upserts and deletes share `lockEntrySettled`.
- A group booking trying one host after another uses `inSavepoint`, so a
  failed attempt doesn't keep its subtransaction until commit.
- A candidate only counts as able to make the meeting with a calendar
  that's written to, which is what `setCalendarState` needs.
- A ban ends a dashboard member's team page: `memberStillHolds` now checks
  it, expiry included, as well as their role.
- Holiday calendars count their settings by revision, and each entry says
  which one it reflects. Comparing timestamps couldn't tell an entry a sync
  wrote from old settings just after they changed from someone's own edit,
  so those entries kept stale cover for good. Someone's edit is stamped
  with the revision it was made against, so it still stays.

The branch's migration is regenerated: `settings_changed_at` becomes
`settings_revision`, and entries gain `source_revision`. Both tables are
new on this branch, so there's no existing data to move.
- A session's new host gets an invitation per seat, as the old host and any
  group's host do. With only one, that seat cancelling took their only
  invitation away while the session went on. A seat standing in for another
  isn't needed any more, so `seatStillStanding` goes.
- The team page's "who's leaving" follows the booking's hosts, so a second
  move doesn't send the host who has just left.
@pumfleet
pumfleet enabled auto-merge November 5, 2026 13:54
@pumfleet
pumfleet added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 09d0564 Nov 5, 2026
31 checks passed
@pumfleet
pumfleet deleted the worktree-ooo branch November 5, 2026 13:59
@linear-code

linear-code Bot commented Nov 5, 2026

Copy link
Copy Markdown

ENG-8134

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant