No description
  • Python 83.5%
  • HTML 9.6%
  • JavaScript 3%
  • CSS 2.4%
  • Nix 1.2%
  • Other 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
xbazzi 3a9cf1a1f2
All checks were successful
Build and deploy / build-and-deploy (push) Successful in 1m53s
feat: we can now publish one week at a time to google calendar
2026-08-02 22:33:51 -05:00
.forgejo/workflows idk anymore, sending it 2026-07-29 18:56:32 -05:00
.vscode feat(up-we-go): deploy to k8s with forgejo actions 2026-07-28 23:14:02 -05:00
app feat: we can now publish one week at a time to google calendar 2026-08-02 22:33:51 -05:00
data feat: Calendar + GMeet integration 2026-07-30 23:34:54 -05:00
deploy fix: switch from buildkit to docker native 2026-07-28 23:21:43 -05:00
img feat: add images 2026-07-27 23:05:40 -05:00
migrations feat: add remove all meetings from google calendar feature 2026-08-02 21:31:25 -05:00
scripts feat: Calendar + GMeet integration 2026-07-30 23:34:54 -05:00
tests feat: we can now publish one week at a time to google calendar 2026-08-02 22:33:51 -05:00
.dockerignore feat(up-we-go): deploy to k8s with forgejo actions 2026-07-28 23:14:02 -05:00
.env.example feat: Calendar + GMeet integration 2026-07-30 23:34:54 -05:00
.envrc feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
.gitignore feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
alembic.ini feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
CLAUDE.md feat: Calendar + GMeet integration 2026-07-30 23:34:54 -05:00
coaching-problem.md feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
devenv.lock feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
devenv.nix feat: add remove all meetings from google calendar feature 2026-08-02 21:31:25 -05:00
devenv.yaml feat: mentorship scheduling system of record (Phase 1) 2026-07-27 23:00:41 -05:00
pyproject.toml feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00
README.md feat: Calendar + GMeet integration 2026-07-30 23:34:54 -05:00
uv.lock feat: CP-SAT optimizer preview and onboarding call (Phase 2) 2026-07-28 18:58:30 -05:00

EZ Scheduler

Self-hosted mentorship scheduling system for a coach running rolling 12-week C++ mentorship cohorts (up to 20 active mentees; a 20-minute onboarding call followed by 6 biweekly sessions each).

Mentee availability heatmap Coach working windows and slot inventory

Status: Phase 3 (Google Calendar). The schedule reaches a real calendar.

Architecture

  • Custom app — owns program state (mentees, availability, slots, sessions, proposals, audit)
  • PostgreSQL — authoritative store
  • OR-Tools CP-SAT — scheduling optimizer, proposing only
  • Google Calendar — booking provider, behind a swappable adapter
  • Grafana — read-only dashboards (Phase 4)

Getting started

devenv shell         # direnv does this automatically on cd; syncs deps via uv
devenv up -d         # starts postgres, runs migrations, serves the app — the one command

Then open http://127.0.0.1:1337.

devenv up runs postgres and the app as a pair of managed processes: the app process waits for postgres to report ready, runs alembic upgrade head, then execs uvicorn with --reload. devenv shell alone starts neither — only devenv up/devenv up -d does, and first start also runs initdb, which takes a few seconds. Use devenv up without -d if you want to watch both processes' logs in the foreground instead; devenv processes logs app works either way.

Individual steps remain available as scripts when you need them directly — app:install, app:migrate, app:run, app:test — for example to run the server without --reload under a debugger, or to re-run migrations by hand.

uv.lock is committed alongside devenv.lock, so the Python resolution is pinned the same way the toolchain is. app:install runs a plain uv sync and will update the lock when pyproject.toml changes — the container image built for the cluster should use uv sync --frozen instead, so it installs exactly what was tested.

Blackout dates

One-offs the weekly working windows cannot express — travel, illness, a holiday. Add them on /coach; a whole day by default, or a time range.

Nothing is scheduled inside a blackout. A session that already falls in one is displaced: it moves to the nearest opening that same week and the rest of the mentee's programme stays exactly where it is. That is the whole point — without it, one bad date invalidates the entire series candidate and a week away would relocate somebody's whole twelve-week programme.

Setting Default Meaning
DISPLACEMENT_MAX_DAYS 7 How far a displaced session may land from its original date. 0 turns displacement off, restoring all-or-nothing.
DISPLACEMENT_SAME_WEEK true Also confine it to the same MondaySunday week. Off, DISPLACEMENT_MAX_DAYS is the only bound.

Displacement fires for one-offs only — a coach blackout or a mentee's dated exception. A session outside the mentee's standing weekly grid is not displaced, because that means the slot is wrong for them and the answer is a different slot.

A session with nowhere legal to go is left where it is and the candidate stays honestly infeasible, rather than the session being silently dropped. Held and locked sessions are never moved by anything — use the per-session move on the mentee page for those.

The roster files

data/mentees_entries.csv and data/coach_hours.csv are the source of truth — edit them and re-run. data/README.md documents the columns; the short version is one row per mentee, with availability packed into a single cell as Tue 01:00-04:00; Sat 16:00-04:00, and meetings already held recorded as onboarding_call plus sessions_held.

The mentee file is maintained in Google Sheets, so a CSV export can be saved straight over it — the loader takes M/D/YYYY dates, CRLF line endings and a UTF-8 BOM as they come. Slash dates are read month-first; a sheet on a day-first locale is refused with an explanation rather than misread by five months.

Adding mentees to a running app, including the live one:

roster:import --dry-run      # check it parses and see what would change
roster:import                # create whichever mentees are new
roster:import --coach-hours  # also coach windows + slot series
roster:import --local        # target app:run on :1337 instead of the cluster

Posts to the app's own form endpoints, so every hard constraint, validation message and audit event applies exactly as if the rows had been typed into the UI. Only new mentees are created — matching is by email — so appending to the CSV and re-running is the whole workflow. It reaches the cluster through a port-forward (the ingress is behind tinyauth), which needs KUBECONFIG set.

Rebuilding the dev database — destructive, dev only:

db:seed --dry-run    # parse, validate and print; touches nothing
db:seed              # wipe and repopulate

db:seed TRUNCATEs every table and refuses unless APP_ENV=dev, which is why it is not the tool for a live database.

CSV so the roster pastes straight into a spreadsheet, and one row per mentee rather than one per availability window — the alternative spreads a mentee with nineteen windows across nineteen rows with their email repeated or missing.

data/students_so_far.txt is the raw material these were transcribed from and is kept for provenance; nothing reads it. The loader refuses to run unless APP_ENV=dev, and validates before it writes anything: unknown timezone, duplicate email, unparseable span, or sessions held without the onboarding call that precedes them all stop the run with the offending line number.

Seeded mentees are left pending with no slot. Running the solver and approving its proposal is what materializes the schedule.

Google Calendar

The schedule is published to the coach's Google Calendar, and the coach's existing commitments are read back into the solver as hard constraint 12.

Day to day this is a button: /coach lists what is queued and publishes it. The CLI is for the first run and for debugging, since it prints Google's actual errors rather than putting them in a log.

google:auth                  # one-time consent; prints the refresh token
calendar:sync --dry-run      # what the next publish would send, sending nothing
calendar:sync                # publish the queue here, in the foreground
calendar:backfill            # queue every future meeting not yet published

Everything is off until GOOGLE_CALENDAR_ENABLED is set. See .env.example for the full list; the Google Cloud setup — a project, the Calendar API, a published consent screen, a Desktop-app OAuth client — is documented at the top of scripts/google_auth.py.

OAuth as the coach, not a service account. A service account cannot invite attendees without Workspace domain-wide delegation, so every event would land as a private block with nobody on it. The cost is one browser consent; the refresh token then lasts until it is revoked — provided the consent screen is published. Left in Testing, Google expires refresh tokens after seven days, and the symptom is an integration that works perfectly for a week.

Mentee invites are off by default (GOOGLE_INVITE_MENTEES). Turning them on emails real students the moment the queue is next published — the whole current schedule at once, mistimings included. Watch a few events land on your own calendar first.

Meet links are independent of invites (GOOGLE_ADD_MEET_LINK, on by default). Every coaching event gets its own conference — video link, dial-in number and PIN — whether or not anybody is invited to it, because with invites off the link is precisely what the coach sends the mentee themselves. Gating it on invites would mean adding a call by hand to every meeting.

The requestId on the create request is the session id and nothing else. Google ignores a conference request whose id repeats, so a stable one means the first write mints the room and every later write leaves it alone — which is what makes it safe to send the request on updates too, and that in turn is what backfills a link onto events written before Meet was switched on. Deriving the id from anything that changes when the meeting changes (schedule_version, a timestamp) would mint a new room on every move and silently invalidate a link the coach had already sent out.

Publishing is a decision, not a side effect

Changes wait by default. Move meetings around, drag mentees between slots, re-run the solver — nothing reaches Google until Publish is pressed on /coach, which lists exactly what would be sent first:

Change Meeting New time (UTC)
update Carl Luo's session 3 of 6 Tue 2026-08-11 17:00
remove Kelvin Lee's onboarding call

This matches how the rest of the system behaves — the solver proposes and the coach approves — and it is the difference between one email and three: rearranging an afternoon is a working session, not a series of decisions, and several changes to one meeting collapse into the single call shown. Set GOOGLE_AUTO_SYNC=true to publish as changes happen instead, in which case the background worker drains every GOOGLE_SYNC_INTERVAL_SECONDS and the button still works for anything queued.

The panel also offers to queue meetings that have never been sent — the initial seeding of an existing schedule. That is deliberately a separate button from Publish: on the one run where it is the whole roster at once, the list is still read before it goes.

The outbox

Every schedule change stages a row in outbox_event inside the transaction that made it. Nothing calls Google from the code that changes the schedule, so a provider outage delays delivery rather than failing an assignment, and a rolled-back transaction takes its queued work with it.

Rows are staged by a single before_flush listener rather than by calls at each mutation site. Session rows are now born, moved, cancelled and deleted in ten places — assign_slot, apply_proposed_schedule, schedule_meeting, reschedule_session, unschedule_meeting, place_on_slot, clear_slot, release_slot, set_enrollment_status and reinstate_enrollment — and hooking each is ten chances to miss one. A missed one is invisible: the app is right, the calendar is stale, and nothing reports a disagreement. Reading the outcome of a flush covers every path by construction, including paths written later.

A queued row says only that a meeting changed; the worker reads it fresh and sends its current state. So a meeting moved three times before the worker wakes becomes one API call with the final time, and several queued rows for one meeting collapse to the last. That is also what makes the queue safe to replay — sending current state twice is idempotent, sending a diff twice is not.

outbox_event.session_id carries no foreign key, the same deliberate omission as change_proposal.slot_series_id. The most important row the table ever holds is the one for a session that has just been deleted: release_slot drops session rows outright and their calendar entries still have to come down. CASCADE would delete that instruction along with the session; SET NULL would keep it while losing track of which meeting it was for.

Event ids are derived from the session id, so a create that times out after Google committed it produces a 409 on retry rather than a second event. Google's id alphabet is base32hex — digits and av — which is why the prefix is coaching and not ez: z is not in it, and the whole scheme fails with a 400 that says only "Invalid resource id value".

That derivation is also why moving a meeting moves its event rather than cancelling one and creating another. A session keeps its row and its id when it is re-timed — by the planner, by a drag on the board, or by an approved solver run that hands a mentee a different standing slot — so all six meetings are PATCHed in place and an invited mentee sees "this event moved", not a cancellation followed by a fresh invitation. The exception is release_slot and clear_slot, which delete the session rows outright: those events are cancelled, and a later assignment creates new rows, and therefore new events.

A settled meeting is never unpublished. completed and missed are not "syncable", so without an explicit guard on both the staging and draining sides, marking a session done would fall through to "take it off the calendar" and erase last Tuesday. Both guards are pinned by tests that fail without them.

Retries back off (1, 2, 5, 10, 20, 30 minutes) and a change that still fails is parked, not dropped — a 503 is worth waiting out, a 403 for lack of write access never resolves. Parked changes are listed on /coach with their error and a button to requeue them. Nothing fails silently, because a worker that gives up quietly leaves the app confidently reporting a schedule the calendar never received.

Busy times, and why they fail soft

SolverInput.busy is filled from the coach's calendars, which is what makes hard constraint 12 real rather than merely wired. This reads events.list rather than freebusy: freebusy answers with times only, and the one thing that must be filtered out is our own coaching events — on the same calendar by default — or the solver would see every session it has already booked as an immovable outside commitment and refuse to move any of them. Reading whole events also means an event marked "free", a cancelled one and an invitation the coach declined correctly block nothing, and an all-day event blocks the day.

A failed read is recorded on the run and shown on the proposal page rather than raised. A run made blind to the coach's calendar is not wrong — every constraint the database knows about still holds — it is less informed, and refusing to produce any schedule until a third party comes back is a worse answer. The failure mode of making it fatal is a coach who turns the integration off.

Why a local Postgres when the homelab has a cluster?

The devenv Postgres is the dev database. The deployment target is still the homelab CNPG cluster — nothing here is coupled to running locally:

  • app/db.py builds its engine from DATABASE_URL alone, so pointing the app at pg-cluster-rw-lb (10.67.1.5) is a one-variable change.
  • Phase 1 iterates on schema. db:reset is a literal DROP DATABASE, which is not something to aim at a shared, WAL-archiving, backed-up cluster.
  • The cluster is deliberately disposable (cluster:down + up over staged upgrades, with the live/↔prev/ ping-pong recovery on bringup). Local dev shouldn't block on a cluster rebuild.

Keep the local major version matched to the cluster's (ghcr.io/cloudnative-pg/postgresql:17.2) — the app only ever sees a URL and won't warn about the drift. Bumping the major means wiping .devenv/state/postgres so devenv re-runs initdb.

In the cluster the database comes from CNPG. This is wired: the cnpg-app-coaching release in homelab/k8s/helmfile-apps.yaml uses the cnpg-app-db chart to create the Database, DatabaseRole, credentials Secret (mirrored into the coaching namespace as coaching-db-creds) and both halves of the Cilium policy for coaching → databases:5432. The password is SOPS-encrypted at homelab/k8s/base-infra/cnpg-app-coaching/secrets.yaml.

One role for now, not the three (mentor_app / mentor_worker / grafana_reader) sketched earlier — cnpg-app-db is one role per release, so splitting them means additional releases. Worth doing when Phase 4 adds a read-only Grafana datasource; there is nothing to separate privileges for while the only client is the app itself.

Deploying

Push to master and Forgejo Actions does the rest — see .forgejo/workflows/deploy.yml. It builds deploy/Dockerfile, pushes gitgud.boo/xbazzi/coaching:{sha-<short>,master}, and rolls the Deployment out via a ServiceAccount that can patch that one Deployment and nothing else.

The job runs runs-on: homelab — a self-hosted Forgejo runner on the homelab Swarm host prod2. It cannot run on gitgud.boo's shared runners: those have no route to the cluster's 10.67.0.0/22, so the rollout step times out there. If a job sits in "waiting", that runner is down rather than the workflow being broken.

Keep deploy/Dockerfile classic-builder compatible — no RUN --mount, no # syntax= directive. The runner builds with the plain docker CLI, which ships no buildx plugin, and Docker CLI 23+ removed the old daemon-side BuildKit path, so BuildKit-only syntax fails the build outright.

If the rollout step ever fails the image is still pushed, but nothing deployed it — run coaching:rollout from the homelab repo.

The Google credentials are cluster config, not repo config. GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and GOOGLE_REFRESH_TOKEN belong in a SOPS-encrypted Secret alongside coaching-db-creds, with the rest of the GOOGLE_* settings as plain chart values. Nothing here reads a credentials file from disk, which is what keeps the container's read-only root filesystem workable — every one of them is an environment variable.

The refresh token is minted on a machine with a browser (google:auth) and pasted in. It is the one piece of state the cluster cannot regenerate for itself, so losing it means re-running consent, not a rebuild.

Migrations run as the pod's migrate initContainer (alembic upgrade head), so a migration and the code that needs it always ship together; a failed migration keeps the previous ReplicaSet serving and fails the CI job.

To test the image locally the way the cluster runs it — non-root, read-only root filesystem:

docker build -f deploy/Dockerfile -t coaching:local .
docker run --rm --read-only --tmpfs /tmp --network host \
  -e DATABASE_URL="postgresql+psycopg://mentor:mentor@127.0.0.1:5432/mentor_scheduler" \
  coaching:local

The image is glibc-based (python:3.12-slim-bookworm) on purpose: ortools publishes manylinux wheels only, and they link against the distro libstdc++ and libz.

Tests

app:test runs two tiers, split by whether a test needs Postgres rather than by filename:

  • Puretest_scheduling, test_availability, test_insights, test_calendar, test_optimizer, and the resolver half of test_timezones. Cadence, parity, DST arithmetic and the whole CP-SAT model. No database, always runs, about two seconds for the lot.
  • Database-backed — everything carrying requires_db: the *_flow modules plus test_confirmations, test_mentee_edit, test_session_undo, test_booking_outbox and the form half of test_timezones. The full HTTP flow against real Postgres. Skips when TEST_DATABASE_URL is unset or the server is down, so it can't touch dev data and pytest still works with devenv up stopped.

test_booking is pure: the Google client runs against a fake transport, so the exact JSON that would reach Google is asserted on without a network. That is deliberate — a live test would confirm Google works, which was never the question.

conftest blanks the Google credentials at import time, unconditionally, so the suite is incapable of reaching Google rather than merely uninterested. A developer's .env holds working credentials — that is what it is for — and a test that enables the integration without installing a fake provider would otherwise publish to whatever GOOGLE_CALENDAR_ID says, which defaults to primary. This has already been a near miss: the only thing that stopped one run creating real events was a fixture that happened to override the calendar id with a name Google 404s on. Blanking the credentials, rather than only the enable flag, means that mistake now fails loudly. test_booking_no_network pins it. test_booking_outbox is database-backed and goes in through the HTTP surface on purpose: a test that called program.assign_slot directly would pass just as happily if the flush hook only fired for that one function, which is the bug the design exists to prevent.

test_timezones.py is deliberately mixed and marks the database cases individually — a reminder that the tier is a property of the test, not the file.

TEST_DATABASE_URL supplies connection details and a name prefix, not the database actually used. Each run creates mentor_scheduler_test_<pid>_<token>, migrates it, and drops it on the way out. That's because the tests TRUNCATE between cases: two runs sharing one database deadlock — one holds row locks while the other waits for the AccessExclusiveLock that TRUNCATE needs — which surfaces as dozens of unrelated failures rather than anything resembling a lock error. Concurrent runs (two terminals, an agent alongside you) are now safe.

A killed run can leave its database behind; db:test-clean drops the strays and is safe to run while other tests are in flight.

What works today

  • Mentee intake and edit, with fuzzy IANA timezone resolution and a pick list that works offline
  • Coach working windows, tiled into an A-week/B-week 30-minute slot inventory
  • Reserve and disable flags per slot series, with occupancy shown per slot
  • Mentee availability: paintable weekly 30-minute grid (available / preferred), optionally split by A/B week, plus one-off date exceptions
  • Manual standing-slot assignment, materializing six concrete sessions plus the onboarding call, gated on the mentee's availability with a deliberate override
  • Intake opens a pending enrollment (an onboarding call + 6 sessions, 14-day cadence), with the programme length and any meetings already held enterable on the form for someone you have been seeing already
  • Session progression (complete / missed) with one-click Undo, and enrollment graduation / withdrawal with Reinstate to undo a mistaken close
  • Roster with standing slot, next session and N/20 capacity
  • Calendar: week and month views with A/B parity, conflict detection, and a /calendar/events.json feed
  • Audit events on every mutation
  • Slot-scarcity scoring and a mentee availability heatmap on the coach page
  • Admissions: per-applicant earliest feasible start, what blocks each one, and which upcoming graduation would unblock them
  • Solver: whole-roster CP-SAT optimization producing a reviewable proposal, with infeasibility explanations, an alternative schedule, before/after views and one-click approval that writes to Postgres only
  • Google Calendar: approved schedules published through a transactional outbox on a Publish button, a Meet link per meeting, the coach's own commitments read back as a hard constraint, optional mentee invites, and parked failures surfaced on /coach
  • Confirmation prompts on destructive actions (see below)

Enforced already: slot exclusivity, the 20-mentee cap, mentee availability, an onboarding call before every programme, no releasing a slot with completed work, no disabling an occupied slot, no session materialized in the past.

Migrations run to 0007. 0004 adds the solver tables; 0005 widens the session ordinal to admit the onboarding call at 0; 0006 adds coach blackouts; 0007 adds the calendar outbox. All downgrade cleanly, and 0005's downgrade deletes onboarding calls rather than renumbering them — there is no ordinal in 16 for them to become that is not already taken.

The onboarding call

Every mentee has a 20-minute onboarding call before their programme starts. It is stored on the same enrollment as ordinal 0, so ordinals 16 still mean what they always did on every page, in every stored audit event and in the brief ("Exact tracking of sessions 1 through 6"). Seven meetings, six sessions; enrollment.session_count stays 6 and "3 of 6" never counts the call.

Three rules govern it:

  • Session 1 follows it by 1 to 7 days (INTRO_LEAD_MAX_DAYS). The lower bound is not configurable: a call and the first real session hours apart is one meeting split in two.
  • It obeys the mentee's availability like any other meeting. Exempting it would let the solver book one at an hour they had explicitly ruled out.
  • Nobody starts without one. A series with no free opening in the week before it is unusable, and the solver picks a different slot — or defers the start.

It is a 20-minute meeting that occupies a whole 30-minute slot occurrence. The entire inventory, the availability grid and every overlap check assume one 30-minute grid; a finer one for a single meeting type would buy a little capacity at the cost of that invariant. The last 10 minutes are simply not booked.

The call is not on the standing series, and that is the point. A slot series held by an incumbent is still free on every occurrence outside their twelve weeks, and a one-off call is exactly what can use it — which is why a mentee free at one clock time on both parities can be onboarded on the opposite-parity occurrence a week before they start. Pinned to a single parity, they cannot, and the solver says so.

Assigning by hand also schedules one, greedily (program.find_intro_opening). Where nothing fits it assigns anyway and the mentee page flags the gap: refusing would make the manual path unusable exactly when the calendar is tight, which is when the coach most needs it.

Mentees you have already met

Scheduling refuses to materialize a session in the past — correctly, since that is a scheduling operation — and Complete/Missed only work on a row that already exists. So a coach who has been seeing someone before the system knew about them needs a way to write down history, or it would have to be invented in the future and then marked done, putting a completed session on next Tuesday.

Two ways in, for two different situations:

  • At intake, a count: "meetings already held: 2". Meetings happen in order, so that is the onboarding call and session 1. The quick path, and the common one.
  • On the mentee page, one meeting with its date. Precise, and how you correct a date or add one the count did not cover.

The count deliberately dates only the most recent meeting, from the optional "when you last met" field. Earlier ones are recorded without a time — the truthful encoding of "we know this happened, we do not know when", rather than back-dating them on an assumed cadence and presenting a guess as history. Undated is handled everywhere it matters: a held session pins its ordinal whether or not it has a time, and the calendar filters out rows with no scheduled_start.

That one date still earns its place, because it is what the next meeting is anchored to.

What the last meeting anchors

scheduling.next_start_floor is the single rule, shared by the solver and manual assignment so the two cannot drift apart. The gap it enforces after a held meeting depends on which meeting it was:

Last held Next meeting lands
the onboarding call 1 to INTRO_LEAD_MAX_DAYS days later (a week)
a numbered session a full cadence_days later (a fortnight)
nothing dated no anchor; the floor is today

Both halves matter, and both were wrong before they were written down:

  • The call was treated as a fortnightly anchor, so a session 1 correctly placed a week after it scored exactly as badly as one placed a week too early.
  • More seriously, the floor after any held meeting was "tomorrow". The floor is what delay_days measures from, and minimising delay is stage 4 while cadence deviation is stage 6 — so the solver put session 2 the day after session 1 and called it optimal. Ordering the objectives correctly does not help if the thing they measure against is wrong.

Inside its window the lead time is free: anything from one to seven days after the call costs nothing, and only distance outside the window is penalised. A fortnight after a session is a point rather than a range, so that one is measured as a plain difference.

An undated held meeting anchors nothing. We do not know when it was, so it cannot push the floor anywhere — which is the other reason to fill in last_met_at.

Everything downstream already coped, which is why this is a recording feature and not a scheduling one: open_ordinals skips held ordinals, needs_intro stops offering an onboarding call once any meeting has happened, and both the solver and manual assignment fill in only what is left.

Removal is narrow on purpose. A row that record_past_session created can be deleted, because the way to fix a wrong date is to re-enter it; a session that was scheduled and then held is the programme record, and the way back from marking that one is Undo. Which is which is read from the audit trail rather than stored in a column, the same way _status_before_marking reads the previous status — it is a fact about how the row came to exist, and a column could disagree with the history.

Scheduling invariants

The cadence advances on the local calendar, not by adding 336 hours in UTC — a series crossing a DST boundary keeps its 17:00 wall clock while its UTC time shifts. A/B week parity is counted in whole weeks from a fixed Monday (PARITY_EPOCH) rather than from ISO week numbers, which would flip parity in 53-week years. Local times that a DST transition makes nonexistent or ambiguous are flagged rather than silently resolved.

Set COACH_TIMEZONE to the coach's real zone, not UTC. Coach windows and slot series are stored as minute-of-day in that zone. Leaving it at UTC means the coach's actual working evening drifts by an hour at each DST transition — a 23:3002:00 UTC window is 17:3020:00 Chicago in January but 18:3021:00 in July. Changing it reinterprets existing slots, so set it before building inventory or regenerate afterwards.

A coach window that runs past midnight is entered as one window with the end earlier than the start (23:30 → 02:00); it's stored as one row per day. The two rows tile to exactly the same 30-minute series a single overnight window would, so nothing is lost. One wrinkle on a Sunday→Monday wrap: weeks start on Monday, so the two halves fall in different weeks and carry opposite A/B parity — see split_overnight_window.

Reinstating a closed enrollment (Reinstate on the mentee page) undoes a mistaken Withdraw or Graduate. If the old standing slot is still free it is reopened and the cancelled future sessions go back to planned — a true undo. If someone else has since taken the slot, the enrollment reopens as pending and the dead future sessions are dropped so a new slot can be assigned. Sessions that actually happened are never altered, and a mentee can never end up with two open enrollments. For a genuine re-admission rather than an undo, open a fresh enrollment instead.

Assignment materializes only the ordinals still missing, so an enrollment that completed sessions 12 before a pause resumes at 3 rather than restarting at 1 (which would violate the per-enrollment ordinal uniqueness). actual_start_date is preserved across a resume.

Availability is checked per concrete session, not per slot. Slots are fixed in the coach's local time while availability is recorded in the mentee's, and the offset between two zones is not constant: the US and EU change clocks three weeks apart, so a "Monday 17:00 Chicago" series sits at 23:00 London for four of its six sessions and 22:00 for the other two. A mentee can therefore be available for part of a series and not the rest.

An unpainted grid cell means hard unavailable (hard constraint 5). A mentee with no grid on file is treated as unknown rather than unavailable, so assignment isn't blocked before availability has been captured — whether the check ran is recorded on the assignment's audit event.

Mentee timezones are resolved from free text at the form edge (domain/timezones.py): a coach types "Duba", not "Asia/Dubai". Ambiguous fragments are refused rather than guessed, since silently picking one of six America/* zones would put a whole availability grid hours out.

Scarcity, not popularity

A slot's scarcity score is not how many mentees want it. Each mentee spreads one unit of need across the slots that fit them, so a slot that is somebody's only option scores 1.00 from them while one of twenty options scores 0.05. Summed per slot, this ranks the slots whose loss would actually strand someone above the merely busy ones — which is the question worth asking before disabling a slot or spending one on a flexible mentee.

The heatmap is resolved against one concrete week (the coming one) rather than as a fixed offset, and says so on the page. Converting a mentee's weekly availability into coach-local time depends on the daylight-saving offsets in force that week, and a cell can legitimately land on a different weekday than it was painted on — a Tokyo mentee's Tuesday morning is the coach's Monday evening.

The optimizer

/solver runs the whole roster through CP-SAT and produces a proposal. Nothing on the schedule moves until the coach approves it, and even then nothing reaches an external calendar — that is Phase 3.

Two numbering schemes appear below and they are not the same. Objective N is the brief's list of nine soft constraints. Stage N is one of the six solves this implementation actually runs; several objectives share a stage.

A candidate is a plan, not a slot

The brief specifies two levels of decision variable: series_assignment and session_assignment. For the six programme sessions only the first is free, because the second is determined by it — choosing a standing slot fixes six concrete timestamps, so a second layer could only disagree with the first. Each SeriesCandidate therefore carries its materialized sessions and is judged on those: availability, overlap and the program window are all per-session questions.

session_assignment is a free variable for the one session that needs it: the onboarding call (IntroCandidate). Its time does not follow from the standing slot — it has to land in a short window before session 1 at whatever concrete opening fits — so it is a choice of occurrence, not of series. The remaining freedom the second level exists to provide, one-off moves and make-up sessions, is Phase 5; the make-up objective is wired at a constant zero rather than omitted, so adding it later changes no stage ordering and no stored shape.

Both kinds of variable are reduced to a TimeClaim before the overlap sweep. Checking them separately would silently permit the collision most likely to happen in practice — an onboarding call landing on top of somebody's session.

Overlap is enforced per half hour rather than per pair: one add_at_most_one over every claim touching a bucket. Twenty mentees competing for the same half hour is one constraint that way and a hundred and ninety written pairwise.

Starts can be deferred. build_candidates generates each (enrollment, slot) pair at several start offsets (SOLVER_START_OFFSETS). This is not a nicety: a series whose next occurrence is a few days out leaves no room for the call that has to precede it, and with only one offset the solver would report the mentee unplaceable rather than starting them a fortnight later — halving usable capacity, since A-week and B-week series start in alternate weeks. Stage 4 keeps the wait short.

Keeping a slot means keeping its times. For an incumbent's own slot the candidate reuses the timestamps already on the books instead of re-materializing from today. Re-materializing agrees only while the existing series sits exactly on the slot's fortnightly grid; anywhere else (a non-14-day cadence, a session deferred by hand) every incumbent would be reported as needing a move, and "minimize incumbent moves" would quietly be measuring calendar drift.

Hard vs. rankable

Three of the brief's constraints are not enforced during optimization, because they are soft objectives wearing hard-constraint clothing:

Constraint Treatment
Every enrollment gets six sessions (1) objective 1 — enforcing it makes an over-subscribed roster INFEASIBLE, so the coach gets nothing instead of a schedule plus a named problem
Incumbents don't move objective 2
Reserve slots are protected objective 5

The rest are hard: availability (5), the program window (10), the 20-mentee cap (11), busy time (12), slot exclusivity (13), session overlap (3, 14) and the onboarding call. Ordinals stay ordered (8) and nothing lands before admission (9) by construction, via the start floor in _start_floor. Cadence (15) holds because materialize_sessions produces it.

require_onboarding_call is enforced during optimization like the other hard ones. It is an assumption literal anyway so the diagnosis ladder can report it as the thing that bound, which on a tight calendar it often is.

Overlap is checked on concrete timestamps, not slots. Two different slot series can collide once an enrollment's cadence_days differs from the fortnightly slot period, because the series then drifts off its own grid.

Losing a slot is worse than changing one. Dropping an incumbent to nothing is priced inside objective 2 at INCUMBENT_DROP_WEIGHT rather than forbidden. Made hard, one mentee who repainted their availability into a corner would turn the whole run infeasible.

Lexicographic, not weighted

Six solves in sequence, each freezing its optimum before the next runs. The brief's nine objectives collapse into five of them; stage 4 is an addition.

Stage Minimizes Brief's objective
1 unassigned enrollments 1
2 incumbent moves (and drops, priced far higher) 2
3 changes to future sessions — a confirmed one counts 100× 3
4 how long anyone waits to start
5 make-ups (constant 0) and reserve capacity 4, 5
6 preferences, calendar compactness, cadence deviation, undesirable hours 69

Stage 4 is not in the brief's list, because the brief assumed a mentee starts at the first opportunity. Once a start can be deferred — which it must be, or a series with no room for an onboarding call ahead of it makes the mentee unplaceable rather than merely later — something has to say waiting is a cost. It sits above reserve and preferences: a coach would spend protected capacity, or a less-loved hour, to start someone a fortnight sooner.

No single weighted sum can do this. Twenty mentees each gaining a marginally nicer hour will outweigh dragging one incumbent out of the slot they have held for two months, and no choice of weights fixes it — the number of mentees isn't fixed. Weights only ever compare terms inside one stage, where the trade-off is between comparable things.

Fragmentation (the brief's objective 7, inside stage 6) is measured in bookable positions left empty between the day's first and last booking, not clock minutes. A coach who works 10:0011:00 and 17:0019:00 has six hours between those windows that no scheduling can remove; counting it would rank every schedule identically badly. A-week and B-week series at the same clock time are different days here — they never occur in the same week.

Why the run is infeasible, in words

Every bendable constraint sits behind a CP-SAT assumption literal. When no schedule places everybody, the ladder in explanations.py relaxes them one at a time — reserve, then the program window, then the onboarding call, then incumbent moves, then availability, then the cap — and reports the first rung that helps, which is what distinguishes "feasible only by using reserve capacity" from "feasible only by moving an incumbent".

The ladder is cumulative, so reaching a rung proves the accumulated set is sufficient, not that every member is needed. It therefore asks once more with only the tipping constraint relaxed, and reports one concession where one will do. The wording deliberately echoes admissions._blocker; a solver that phrases the same situation differently reads as a second, disagreeing opinion.

Approving

Approval applies the solver's own timestamps through program.apply_proposed_schedule. Re-deriving them would be a second opinion computed against a roster that has moved on since the page was rendered, and the coach would get a schedule they never saw. The onboarding call is applied in the same call as the programme it introduces, so the two land in one transaction — an enrollment with sessions but no call, or the reverse, is a state the coach would have to repair by hand.

Every Phase 1 guard still runs at apply time — the slot must still be free, the cap must still hold, and a held or locked session is never overwritten — precisely because the state may have changed under the proposal. Slots are vacated in a first pass and taken in a second, or a proposal where two mentees swap slots would fail on whichever was applied first.

Only one proposal is live at a time; running the solver marks any earlier pending run superseded. Two live proposals computed against different states would each look applicable, and applying the second after the first would write a schedule nobody reviewed.

Moving an incumbent mid-program is deliberately not routed through release_slot, which refuses once work has happened and deletes every session. Releasing means giving up; moving keeps the enrollment active and re-times what remains. vacate_slot is the half that closes an assignment without touching sessions.

How long it takes, and what "feasible" means

SOLVER_TIME_LIMIT_SECONDS is per stage, so the worst case is six times it. Measured on a 20-mentee roster:

Inventory Variables Result
168 slots (roomy) 6,480 503 ms, optimal
120 slots 4,560 hits the budget on the last stages
80 slots (about half utilised) 3,040 hits the budget on the last stages

Difficulty tracks contention, not size — the largest model above is also the fastest. Packing forty occupancies into eighty half hours with narrow mentee availability is where proving the last tie-break optimal gets hard.

A stage that runs out of time keeps the schedule found so far and marks the run feasible rather than optimal; the proposal page says which stage gave up. That schedule still satisfies every hard constraint and every optimum frozen before it — what is unproven is only the tie-breaking. Raising the budget buys the proof, not a materially better schedule.

A late stage finding nothing must not discard the run. Earlier stages have already produced a valid schedule; throwing it away hands the coach nothing after a minute of work. objectives.solve snapshots the objective vector after every successful stage for exactly this reason — after a stage that finds nothing, the solver holds no solution left to read.

Runs are kept, with their input

solver_run.input_snapshot is a full copy of the problem, not a set of foreign keys. Slots get disabled, mentees repaint availability and enrollments graduate, so a run reconstructed from live rows six weeks later would explain a schedule nobody ever saw. /solver/runs/{id}.json serves the whole thing.

Confirmation prompts

Destructive controls carry a data-confirm attribute; static/confirm.js binds one document-level listener that prompts before the default action runs. The message lives in the markup next to the action so it can state the real cost — how many sessions get cancelled, which slot is given up, and whether an undo exists.

Two rules keep the prompts meaningful:

  • Reversible actions don't prompt. Complete/Missed have a one-click Undo, and Reinstate restores a closed enrollment. Prompting routine reversible work trains people to dismiss dialogs, which weakens the prompts that matter.
  • Only the destructive direction prompts. Disabling a slot asks; re-enabling doesn't. Clearing the availability grid says plainly that nothing is lost until Save.

Saving an empty availability grid is confirmed from availability.js instead, since it depends on what was just painted rather than on the rendered page.

tests/test_confirmations.py fails if any control whose label implies data loss lacks a prompt, so a new destructive button can't ship unguarded.

Traps

Things that cost real time to find, and that look like something else when they bite.

OR-Tools needs two system libraries that nix does not put on the loader path. The ortools wheel is manylinux and links against the distro's libstdc++ and libz. devenv.nix prepends them via withWheelLibs, which every script and process that runs Python includes — enterShell alone does not cover devenv scripts or devenv up processes, so adding a new script means adding the preamble. Without it the failure surfaces one layer down, as numpy reporting libz.so.1: cannot open shared object file, which looks like a broken numpy install. The container image needs both libraries in its base for the same reason.

only_enforce_if([]) is unconditional, not vacuous. An empty enforcement-literal list means the constraint always applies. Passing one where you meant "don't enforce this" silently hard-codes the very thing you were relaxing — and the symptom is a model that gets stricter the more you relax, which reads like a solver bug. model.Assume returns IntVar | None and every consumer guards on is None for this reason; never re-introduce a list.

SOLVER_START_OFFSETS must not be 1. With a single offset, a series whose next occurrence is a few days away has no later alternative, so a mentee with no room for an onboarding call before it is reported unplaceable instead of starting a fortnight on. Because A-week and B-week series start in alternate weeks, that halves usable capacity — and it looks like an availability problem.

Availability applies to both variable kinds. _availability and _reserve_protection iterate data.candidates and data.intros. Add a constraint that only walks one list and the onboarding call quietly escapes it — this shipped broken once and the tests now pin it.

sessions_for/min(ordinal) is the onboarding call, not session 1. Anything that wants "the first session" must filter ordinal != 0 or ask for ordinal 1. Several tests were silently completing the intro before this was caught.

Per-parity availability needs split=1. Posting a grid with A:/B: prefixes but split=0 discards the parity, and the mentee ends up available on both weeks. Easy to get wrong in a test and it makes the case you were building disappear.

A brand-new SQLAlchemy row has None, not the column default. Column defaults are applied by the INSERT, so row.schedule_version += 1 on a row added moments ago raises TypeError. Use (x or 0) + 1.

Route order decides /runs/{id}.json. Starlette matches in declaration order and a path parameter happily swallows a .json suffix, so the JSON route must be declared before the HTML one or it 422s on a UUID ending in .json.

Count and list must come from the same predicate. SolverInput.schedulable means "owed a schedule", not "has candidates" — a mentee for whom no slot works has no candidates and is exactly the person to name. Deriving the unassigned count and the unassigned list from different predicates made the page report a problem it then refused to explain.

A floor is not just "not before". scheduling.next_start_floor is what delay_days measures from, and minimising delay is stage 4 while cadence deviation is stage 6 — so a floor of "tomorrow" after a held session made the solver place the next one the following day and call it optimal. Ordering the objectives correctly does not help if what they measure against is wrong. Any change to the floor needs the anchoring tests in test_optimizer.py re-read.

The code says intro where the UI says "onboarding call". INTRO_ORDINAL, IntroCandidate, INTRO_SESSION_MINUTES, needs_intro. The vocabulary the coach uses is "onboarding call" and all user-facing strings say so; the identifiers were left alone deliberately rather than churned. Do not assume a bug on seeing both.

Session rows are created in two places, and changed in eight more. program.assign_slot (manual) and program.apply_proposed_schedule (approved proposal) create them; schedule_meeting, reschedule_session, unschedule_meeting, place_on_slot, clear_slot, release_slot, set_enrollment_status and reinstate_enrollment move, cancel or delete them. A change that touches how sessions come into existence has to cover all of them — which is why the calendar outbox is a before_flush listener reading the outcome, not a call at each site. Anything else that needs to observe session changes should go the same way rather than growing a ninth call site.

A pending row has no primary key. default=uuid4 is applied by the INSERT, so row.id inside a before_flush listener is None for anything newly added. The outbox assigns one itself; a listener that reads row.id and trusts it will silently record NULL for exactly the rows it most needs to name.

Google event ids are base32hex. Digits and av only. An id containing z — an ez prefix, say — is rejected with a 400 whose entire message is "Invalid resource id value", which reads like a malformed request body rather than a two-character prefix.

Editing .env under devenv up half-works, which is worse than not working. devenv.nix sets dotenv.enable = true, so .env is exported into the process environment when devenv starts — and a real environment variable takes priority over the .env file in pydantic-settings. So after devenv up:

  • a key that was already in .env is pinned to its old value. Editing the file changes nothing, and --reload does not help: the reloader inherits the same environment.
  • a key that is new to .env is not in the environment, so it is read from the file and takes effect at once.

Which means a session that adds GOOGLE_CALENDAR_ENABLED and edits GOOGLE_CALENDAR_ID gets one of the two, silently. devenv up has to be restarted — not the app process, which process-compose would respawn with the same inherited environment. Symptom seen in practice: a settings change that "did not take", against a page proving some other change from the same edit did.

The dev database is not disposable in the way the cluster is. db:reset is a literal DROP DATABASE. Running the solver against dev data is safe — it only writes solver_run/change_proposal rows — but approving a proposal rewrites real schedule state.

Layout

app/
  api/          FastAPI routers
  domain/       SQLAlchemy models + domain logic
    scheduling.py   pure cadence/parity/slot arithmetic + the pinned-session rule
    availability.py pure grid <-> window translation + slot-fits-mentee check
    insights.py     pure scarcity scoring + heatmap projection
    calendar.py     pure week/month bounds + overlap detection
    timezones.py    pure IANA pick list + fuzzy resolution
    program.py      enrollment + assignment operations, hard-constraint checks
    audit.py        audit-event recording
  optimizer/    CP-SAT scheduling optimizer
    candidates.py   pure: what could be scheduled, with every cost precomputed
    model.py        the model: hard constraints + assumption literals
    objectives.py   the lexicographic solve-and-fix sequence
    explanations.py why an infeasible or disruptive run came out that way
    runner.py       the only file here that touches the database
  booking/      External calendar (Phase 3)
    provider.py     pure: what a provider is, and the event wording
    google.py       Google Calendar over stdlib HTTP; no database
    outbox.py       staging in the caller's transaction, and draining; no HTTP
    worker.py       the loop that drains, started with the app
    cli.py          calendar:sync / calendar:backfill
  web/          Jinja templates + static assets (HTMX)
data/           Roster CSVs — the source of truth for db:seed
migrations/     Alembic
scripts/        seed.py (reads data/), google_auth.py (one-time consent)
tests/
dashboards/     Grafana JSON (Phase 4)
deploy/         Container image build context (Dockerfile); the Helm chart
                lives in the homelab repo under k8s/charts/coaching
.forgejo/       CI: build -> push to gitgud.boo -> roll out in the cluster

Everything in optimizer/ above runner.py is free of both the database and OR-Tools' solver calls, which is why tests/test_optimizer.py runs in under two seconds with devenv up stopped.

Phases

  • 1 — Program system of record (schema, intake, roster, manual assignment)
  • 2 — OR-Tools optimizer preview (proposals only, no calendar writes)
  • 3 — Calendar integration (booking provider, outbox, busy times)
  • 4 — Dashboards + alerts
  • 5 — Advanced optimization

Phases 1 to 3 are complete. Every admin page the brief calls for exists, the optimizer generates schedules the coach reviews and approves, and approved schedules reach Google Calendar. Phase 3 landed as Google rather than Cal.com — see Deviations.

Where to pick up

Phase 3 publishes outward. What it does not do is read the coach's edits back.

  1. Drift detection. Move or delete a coaching event in Google and the app never learns. The next change to that meeting silently overwrites the edit, and nothing warns anybody: an earlier version put that warning in every event's description, and the description was deliberately dropped so an invite carries the meeting and nothing else. Noticing is the real fix in any case — a sentence nobody reads was never one. events.list with a stored syncToken would give incremental changes cheaply; the honest first version flags divergence rather than applying it, since applying a hand-move could violate ordering, collision or availability invariants that the app is the only party enforcing.
  2. A renamed mentee has a stale event title. The hook watches session rows, not mentee rows, so changing a name or email leaves every published event saying the old one. Watching Mentee in the same listener and queueing that mentee's future meetings is a contained fix.
  3. Cancellation email on withdrawal. With invites on, sendUpdates=all means a withdrawn mentee is emailed a cancellation for six meetings at once. Correct, and probably not how a coach wants that conversation to start.

Two event types, not one. The brief specifies a hidden 30-minute coaching event type. There are two durations: the onboarding call is 20 minutes (INTRO_SESSION_MINUTES) and a programme session is 30. Google takes explicit start and end times per event, so the stored scheduled_end handles both and this cost nothing here — but a provider with pre-declared event types (Cal.com) needs two of them, or one meeting kind gets the wrong invitation length.

Smaller things worth doing

  • last_met_at is blank for every seeded mentee. Five have a held onboarding call and Alisha also has session 1, but no dates — those were not supplied. Until they are filled in, nothing anchors and the next meeting floors on today.

  • Rename intro to onboarding in the code, if the split vocabulary starts to grate. Mechanical, and the tests cover it.

  • Enrollments that predate the onboarding call. Migration 0005 backfills nothing, so anything assigned before it has no ordinal-0 row. The mentee page flags this and the next solver run offers one — but on a tight calendar that can force an incumbent move, because the call has to precede session 1. Worth a look before running the solver over a full roster for the first time.

  • The solver page has no progress indication. A tight roster can take the better part of a minute and the form just hangs. Either stream it or run it as a background job.

  • explanations.diagnose re-solves up to seven times on an unplaceable roster: once per rung of the six-rung ladder, plus one confirmation that the tipping constraint alone was enough. Each is feasibility-only and cheap, but it is the obvious thing to cache if diagnosis ever shows up in a profile.

  • Alternatives cost a full lexicographic sequence each (SOLVER_ALTERNATIVES). Two would roughly double the run time.

Known gaps, all deliberate:

  • Intake doesn't capture minimum notice for schedule changes, or whether times outside preferred availability may be proposed. The optimizer treats non-preferred hours as a soft cost (objective 6) rather than as forbidden, so the second setting would be a per-mentee override of that; neither has a consumer yet.
  • No make-up sessions. plan_candidate materializes an even cadence and nothing else, so the brief's objective 4 is a constant zero inside stage 5 and ChangeType.makeup never occurs. Automatic make-up scheduling is Phase 5 by the brief's own ordering; the term is wired so adding it changes no stage ordering and no stored shape.
  • Sequential slot reuse isn't modelled. Two enrollments whose programs don't overlap in time could in principle share one slot series. Phase 1 models a standing slot as exclusively held, and a proposal the coach cannot then apply through the normal path would be worse than the lost capacity.
  • The onboarding call cannot be rescheduled on its own. It moves only as part of a whole solver run, or not at all. A one-off "move this call to Thursday" needs the same session-level freedom Phase 5 brings for make-ups.
  • is_makeup is never set, for the same reason. The column and the calendar flag exist; nothing writes them.
  • Nothing reads the coach's calendar edits back. Publishing is one-way; an event moved by hand in Google is overwritten on that meeting's next change, with no warning anywhere. See Where to pick up.
  • A renamed mentee keeps a stale event title. The outbox listener watches session rows, not mentee rows.
  • Admin pages have no authentication. The brief puts that at the ingress.
  • dashboards/ is empty; the Helm chart lands in the homelab repo.

Deviations from the brief

No FullCalendar

The brief specifies FullCalendar Standard. The calendar is instead server-rendered as a plain table, for the same reason the availability grid is: the app stays dependency-free and works without internet access, which matters for something whose first design principle is being fully self-hosted. /calendar/events.json already serves events in FullCalendar's feed shape, so dropping the library in later (for drag-to-reschedule, say) needs no restructuring.

Seven meetings, not six

The brief describes six sessions per mentee. Every mentee now also has a 20-minute onboarding call before the programme starts — see The onboarding call. It is stored as ordinal 0 on the same enrollment, so the brief's "exact tracking of sessions 1 through 6" is unchanged.

A sixth solve

The brief's five-solve example gains one stage, "waiting to start", because a start can now be deferred and something has to price the wait. See Lexicographic, not weighted.

Google Calendar, not Cal.com

The brief names Cal.com as the booking provider. Phase 3 shipped against Google Calendar instead, for two reasons that both come down to what already exists: the coach's own calendar is the thing that has to stay accurate, and mentees already live in Google. A Cal.com deployment would be another service to self-host and another calendar to reconcile with the one the coach actually looks at.

The brief's shape is unchanged, and that was the point of the seam. booking/ is split so only google.py knows the provider: provider.py defines a three-verb BookingProvider protocol, and outbox.py and the solver talk to that. A CalDiyBookingProvider is one file, with no change to the outbox, the worker, the flush hook or the schema.

The parts of the brief's Phase 3 that did not ship are webhook ingestion and the reconciliation job. Both are inbound, and Google's push notifications need a public HTTPS endpoint exempted from the ingress auth plus channel renewal on a timer — a lot of moving parts for a signal that carries no payload and still requires an events.list sync to interpret. Polling with a syncToken gets the same information for a fraction of the machinery, and is what Where to pick up proposes.