> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Availability and engagement clock semantics

> Locked ENG-580 contract for availability writers, all-day null clocks, invitation/offer/request accept, and whether other tables need their own IANA column.

Companion pages: [Event clock writeup](https://docs.soundchecklive.io/engineering/backend/event-clock-writeup.md), [cohort alignment](https://docs.soundchecklive.io/engineering/backend/event-clock-cohort-alignment.md), [problem canvas](https://docs.soundchecklive.io/engineering/backend/event-clock-problem-canvas.md). Ticket: [ENG-580](https://linear.app/soundcheck-live/issue/ENG-580/event-call-times-utc-as-wall-clock-encoding-vs-orgevent-timezone-docs).

Locked 2026-08-18 on `cursor/eng-580-timebound-clock-alignment-b9d9`. Dump counts are the post-Event-align `eng580-fresh` snapshot (`localhost:5436`, availability migration not applied — counts below **simulate** `20260817180000_event_clock_alignment`).

## Locked target

Naive `TIMESTAMP(3)` call clocks are **UTC Instants**. Date-key columns stay calendar dates. `member_availability.timezone` is the IANA for that row's `date`.

| Column | Meaning |
| - | - |
| `member_availability.date` | Calendar day (DATE key) |
| `member_availability.start_time` / `end_time` | UTC Instant, or **null** |
| `member_availability.timezone` | Required IANA for `date` and for open-bound / all-day resolution |
| All-day | `(null, null)` plus IANA. Not `00:00`–`23:59*`, not Instant `05:00`→`04:59` next UTC day |
| Open bound | One clock null. Readers fill the missing side from local start/end of that date in the row IANA |

Two formulas, never mixed (same as Event):

| Id | Use |
| - | - |
| A | Instant → local calendar day: `((instant AT TIME ZONE 'UTC') AT TIME ZONE iana)` |
| B | Wall digits → Instant: `((wall AT TIME ZONE iana) AT TIME ZONE 'UTC')` |

Do not run B on Instant all-day (`5→4`) or on leftover hour-0 walls this pass — those become `(null, null)`.

## Availability writers

Clients always send UTC Instants (RFC3339 with a real offset, typically `Z`) plus IANA.

The server **rejects** a write when:

* a present clock's calendar day in the row IANA is not `date` (`start_time is not on date in timezone` / `end_time is not on date in timezone`)
* both clocks are present and `end <= start` (`end_time must be after start_time`)
* IANA is missing and the org has no resolvable timezone

Overnight or multi-day windows are **multiple requests** (one row per calendar day). Create/update do **not** `splitOvernight`.

All-day from the client is `(null, null)` (or omitted clocks). The server also collapses UTC `00:00`–`23:59*` and local full-day sentinels to `(null, null)` on write.

Web and mobile encode local wall → Instant with `Date.toISOString()` and send the viewer IANA. They must not use `timeToRFC3339` (fake `Z`) for availability.

## Invitation, offer, and request accept

These writers copy UTC Instants onto `member_availability`. They do **not** gate those Instants to `event_date` / offer date / request date. A Chicago 10pm Instant can be the next UTC calendar day and still belong on that calendar `date`.

`00:00`–`23:59*` (UTC date sentinel) and local full-day sentinels collapse to `(null, null)` before insert.

| Path | Clocks copied from | Same-date check? | `end <= start` |
| - | - | - | - |
| Invitation accept | `Event.startTime` / `endTime` (Invitation has no clocks) | No | No extra check (Event Instants are expected `end > start`) |
| Request respond | TIME\_WINDOW: that day's Instants (required on every day). DAY: `(null, null)` | No | **Throw** `end_time must be after start_time` |
| Offer accept | Offer / offer-day Instants | No | Split overnight into **two civil-day rows** (start→EOD, SOD→end). Exact `00:00` end is one open-bound row. Identical clocks still skip. |

Invitation IANA for the new availability row is `Event.timezone`, else org, else `UTC`. Offer and request use the same ladder via `getEventTimeZone(eventId, orgId)`.

TIME\_WINDOW (`Specific time`) requires start/end on **every** request day. `availability_requests.start_time` / `end_time` are still two full timestamps (same `TIMESTAMP(3)` as Event clocks) — a write-through of the first day's Instants for emails/legacy, not a time-of-day type. Respond does **not** paste those UTC hours onto other days.

## Event IANA is still optional

`Event.timezone` stays `String?`. `20260817180000` does **not** `SET NOT NULL`.

Raw prod (`prod-postgres-20260818-post-stamp.dump`, clock migrations **not** applied): **916** Events, **63** real IANA, **0** `'UTC'`, **853** null. The **819** / **97** leftover split is what `17180000` does **locally** (Flex + sibling + location fill) — those labels are not in prod yet. After that file on the earlier 2026-08-18 PM dump: **0** hour 10–23 starts lack IANA; remaining nulls have no `startTime`.

`20260817180000_event_clock_alignment` copies `Organization.timezone` onto leftover null/`UTC` Events when the org already has a real IANA. It does **not** overwrite a real Event IANA. Location fill in `17180000` uses `Event.location` + `Event.longitude` only (not `venues`). Sibling fill skips multi-IANA orgs. WA / NE are not in the location CASE.

`Event.startTime` / `endTime` are already nullable. Making Event IANA required is **not** a prerequisite for nullable clocks.

## Migration `20260817180000_event_clock_alignment`

One file, this order: Event IANA + Instant-ize → availability nullable clocks + all-day null → offer/request Instant-ize → availability evening Instant-ize → overnight hour-0 ends → null → org TZ copy onto leftover null/`UTC` Events.

Overnight hour-0 end nulling stays after every hour 10–23 Instant-ize. Availability `end_time` is still `NOT NULL` until the ALTER. Chicago 16:00–18:00 walls Instant-ize to 21:00–23:00 (still hour 10–23); overnight then Formula-B’s mixed pairs and nulls the end. Instant-ize after that would treat the start as wall again. Event 19:00 walls Instant-ize to hour 0 first; overnight then nulls midnight open bounds. Event overnight nulling could sit immediately after Event Instant-ize (it does not Formula B Event starts). Offer/availability overnight cannot. Org-copy may stay after overnight.

1. Drop NOT NULL on availability clocks; add required `timezone`.
2. Fill IANA: org (skip `'UTC'`), else single Event IANA on the org (`US/Central` ≡ `America/Chicago`), else infer from Instant all-day shape (`5→4` Chicago, `4→3` New York, `7→6` Los Angeles), else `'UTC'`.
3. Null hour-0 same-UTC-day `00:00`–`23:59*` sentinels.
4. Null Instant all-day (`end` next UTC day, \~23h duration, minute-0 start / minute-59 end). That includes Ben Stacy.

Ben Stacy (`org_3G8sNsXlz3GINWkmV0h4XSvJEMx`) has **0 Events**. All **27** availability rows are `05:00` → `04:59` next UTC day — Instant-encoded all-day in UTC−5, not a 5am–4am gig. Infer Chicago and null. One other org has **15** of the same shape (**42** Instant all-day total).

## Dump findings (2026-08-18)

Availability before the simulated null: **1076** rows. **884** hour-0 all-day, **42** Instant all-day, **8** `end < start`, **0** `start::date ≠ date`, **43** `end::date ≠ date`.

After fill + null: **926** all-day `(null, null)`, **150** timed remain.

| Question | Result |
| - | - |
| Do any timed rows still span two **calendar** days after IANA fill? | **One** leftover UTC-crossing timed pair (`14:00`→`04:59` +1 UTC day). Formula A in the row IANA is the **same** calendar day as `date` (offset-era Instant, not wall). `20260817180000_event_clock_alignment` does not rewrite it. |
| Other “off calendar day” leftovers? | Hour-0 / `00:00` **starts** that are UTC-date fragments are still stored as fake-`Z` walls. Do **not** Formula-B those. Inverted hour-0 **ends** null in `20260817180000_event_clock_alignment` (open bound). |
| Request `end <= start`? | **0 / 35** requests, **0 / 50** request days. |
| Offer `end <= start`? | **10 / 134** offers, **19 / 183** offer days before `20260817180000_event_clock_alignment`. Almost all overnight walls on one UTC date (`15:00`→`01:00`, `20:45`→`00:00`, `23:30`→`03:30`). Inverted hour-0 ends null in `20260817180000_event_clock_alignment`. ENG-587 accept writes two civil-day availability rows and two staffing calendar blocks; `00:30` stays a real morning Instant. |

### IANA leftover census (prod stamp 2026-08-18)

Clock migrations are **not** in prod yet. Dump `prod-postgres-20260818-post-stamp.dump` (sha256 `0183bec469b9c1873beb7d8df3dc6ddf062beec96ab0a0a735ca9c6fe711774b`).

Manual prod writes (do **not** put these IDs in Prisma SQL):

* **2026-08-17:** 12 timed-start Event IANAs (leftover-1). Benji Uzo + Test org `Organization.timezone = America/Chicago`.
* **2026-08-18:** 13 org defaults + Event labels on five orgs that had **zero** Event IANA (no `startTime`). Mixed-org Event labels were never in prod — sibling/location fill only exists after `17180000`.

Verified on that dump: **15** orgs have a real TZ (9 Chicago, 4 Los Angeles, 1 New York, 1 `America/Argentina/San_Juan`). All 13 stamped orgs match the intended IANA. The five Event fills landed (Murder House / 3 Room Tent Los Angeles; Animal Andino San Juan; Gone With The West Event Denver + org Chicago; testing Chicago). **0** mixed orgs still have null org TZ.

This file does not overwrite a real Event IANA (`WHERE timezone IS NULL OR timezone = 'UTC'`). Filling Soundcheck Team leftover nulls with Chicago is a blank fill, not a rewrite of New York.

Location fill gaps that needed a human stamp: **WA** and **NE** are not in the `17180000` CASE; CA/TN need a real longitude (dummy `0,0` refuses). Fill does not read `venues`. PostHog `$geoip_time_zone` is last-IP, not home base (Spain IP on the San Juan festival). 3 Room Tent has no public home base — last IP only.

After this file on the earlier PM dump, unlabeled Events dropped to **85** (all no `startTime`). Deploy it **after** the 2026-08-18 org stamps so Soundcheck leftovers copy Chicago. Leftover empty orgs are Chicago-filled so the column can be required; [ENG-588](https://linear.app/soundcheck-live/issue/ENG-588) replaces those Chicago placeholders with Clerk / PostHog where known.

## Should other tables get an IANA column?

Availability needed its own required IANA because the row has a calendar `date`, Instant clocks that must agree with that date, **no `event_id`**, and recurring / open-bound resolution that cannot inherit a gig zone.

| Table / API | Already has IANA? | Add a column this pass? | Writer API |
| - | - | - | - |
| `Organization` | Required `timezone` | No new column. This **is** the organizational default IANA (Event display, `getEventTimeZone`, availability when the client/Event has no zone). Leftover empty orgs are stored as America/Chicago. | Product writer exists: create defaults omitted timezone to UTC; settings update rejects empty/null. `17180000` fills org TZ when Events collapse to one real IANA. Mixed orgs were stamped in prod (two internal test orgs 2026-08-17; eight mixed + five leftover-Event orgs 2026-08-18 — see [cohort alignment](https://docs.soundchecklive.io/engineering/backend/event-clock-cohort-alignment.md)). [ENG-588](https://linear.app/soundcheck-live/issue/ENG-588) replaces Chicago placeholders with Clerk / PostHog where known. |
| `Event` | Optional `timezone` (display) | **Not this pass.** `20260817180000_event_clock_alignment` copies org TZ onto leftover null/`UTC` Events. Require later after migrate-time fill. Clocks are already nullable. | Clients send UTC Instants; IANA is the existing Event field. Web EventForm / EventDetails send bare `HH:MM` plus timezone; `saveEvent` Instant-encodes. Typed address with no coords still has no zone. |
| `Invitation` | No clocks | No. Always has `eventId`. | Accept copies Event Instants. No invite-level timezone. |
| `crew_offers` / `crew_offer_days` | No | **No.** Inherit `getEventTimeZone(eventId, orgId)`. `eventId` is optional (one leftover Mapache offer has none). Add a column only if we later want an offer-level override independent of Event, or if we start gating offer clocks to `date`+IANA (we decided not to). | Offer **create/update** send UTC Instants for call clocks and `expires_at`; clients Instant-encode bare `HH:MM` in Event IANA when known, else viewer IANA (`timezone` on the write payload is stripped before POST). Accept does not take a timezone body field. Leftover hour 10–23 wall `Z` rows Instant-ize in `20260817180000_event_clock_alignment` (Event IANA; skip clocks already equal to Event Instants; skip hour 0–9 overnight ends). Leftover `expires_at` Instant-izes through Event IANA, or org IANA when the offer has no event. Inverted hour-0 ends null in `20260817180000_event_clock_alignment` (open bound; leftover evening day starts Formula B in the same UPDATE). |
| `availability_requests` / days | No | **Not this pass.** Same inherit ladder. `eventId` is optional, so event-less TIME requests fall through to org / America/Chicago. TIME\_WINDOW days must carry their own Instants; request-level clocks are first-day write-through only. | Create/update require per-day start/end in TIME\_WINDOW. Respond copies those Instants. Clients Instant-encode bare `HH:MM` in Event IANA when known, else viewer IANA (`timezone` on the write payload is stripped before POST). Leftover hour 10–23 wall `Z` rows Instant-ize in `20260817180000_event_clock_alignment` under the same Event IANA rules (event-less parent clocks through org IANA). Inverted hour-0 ends null in `20260817180000_event_clock_alignment`. |
| Run of show | No (0 clocks in prod) | No. | Clients Instant-encode section/item clocks in Event IANA. Leftover clocks Instant-ize in `20260817180000_event_clock_alignment` through Event IANA (same writer floor as offers). Prod dump has 0 clocks. Do **not** Instant-ize template `*_clock_minutes` — those are minute-of-day integers. |
| Flex quote | Metadata IANA only | No. Instants already. | Unchanged. |
| `LedgerItem.date` | Business date | No. ENG-581. | Out of scope. |

Do not add a parallel `timezone` on every table that happens to have `start_time`. Add IANA where a calendar `date` and Instant clocks must agree **and** there is no Event to inherit. That is availability. Engagement accept already stores the resolved IANA on the **availability** row it writes.

## Locked 2026-08-18 evening

Instant writers and `20260817180000_event_clock_alignment` ship together. The 2026-08-18 evening dump still has old-writer fake-`Z` walls after the cutoff — do not treat those rows as Instant-era.

Writer floor (locked): device-offset Instant `updated_at < 2026-07-15 00:00:19` → leave. Fake-`Z` from that timestamp onward Formula B in migration, SQL upper bound `< 2026-09-15`. Instant-writer freeze is dump `now()` **2026-08-20 23:19:32 UTC**. **Phase 1/2 code writers** on the branch store real Instants — deploy with the migration.

`00:30` is a real 12:30am Instant, not an open bound. Only exact `00:00` (UTC leftover midnight or IANA civil midnight) nulls as open-ended.

**Offer / request `expires_at`:** leftover walls Instant-ize through Event IANA, or org IANA when the row has no event. All hours. Not compared to Event start. Not required to match `offer.date`. Do **not** Instant-ize `engagement_sms_reply_codes.expires_at`.

**Offer accept (ENG-587):** `end <= start` skip was a guard against inverted clocks, not a product rule. Accept now splits overnight into two civil-day `member_availability` rows (day D start→open EOD; day D+1 open SOD→end). Exact `00:00` stays one start→open row. `start === end` still skips. Manual create/update still reject overnight in one request. Request respond still throws — dump has **0** request inversions.

**Org calendar:** auto-block availability notes stay hidden (`isAutoStaffingAvailabilityNote`). Staffing blocks come from `listAcceptedOfferStaffingByOrgAndDateRange` / invitation sibling. Two availability rows alone do not paint two calendar blocks. Staffing emits one block per civil day and fetches `o.date >= from - 1 day` so the morning-after day is visible. Dedupe key includes date, so both days survive. **API-00 (Phase 4/5 branch):** accepted all-day offer rows with null clocks resolve to IANA civil-day bounds (`civilDayBoundsInTimeZone`), not UTC `00:00Z..23:59:59Z`. **CAL-03 / STAFFING-02 (branch):** accepted ICS/feeds project per civil day; staffing truth uses recipient-day rows.

**Engagement inbox (CLIENT-02, Phase 4/5 branch):** user and org engagement list/get responses include `event_timezone`. Display keeps RFC3339 Instants until a final zoned format lands. Crew-offer create/update/send/accept/decline mutations attach the resolved Event→org zone, not only list/get.

**Flex `#1328` / `#1387`:** those PRs stored Formula A of `flex_quotes` Instants as Event fake-`Z` walls so leftover UTC readers showed the watch time. The Flex writer now copies the quote Instants again. `20260817180000` restores rows that still equal that Formula A projection. Origin/dev `20260818140000` Instant→walls Flex/HubSpot-linked quote-equal rows; do not edit that file (prod checksum). It is already applied on prod (`2026-08-18 20:15:38Z`) and stamped `updatedAt`, so the backfill hour is not a writer discriminator. `20260819160000_flex_quote_instant_restore` undoes `18140000` after merge / on fresh apply order. Quote columns stay Instants. Phase 2 Instant readers are on the branch — deploy them with the migration so restored Instants display in Event IANA, not as UTC digits.

2026-08-19 flex-wall dump (clock migrations **not** applied): **36** Flex-linked Events. Start: **3** still quote Instant, **33** diverged, **31** Formula A. End: **10** quote Instant, **26** diverged, **24** Formula A. The other diverged clocks are not Formula A — leave them.

## Still open

* **Leftover timed availability.** Same-UTC-day hour 10–23 evenings, hour 1–9 walls, and hour-0 prefixes (`0→00:30`, `0→1–9`, `0→evening`) Instant-ize in `20260817180000_event_clock_alignment` through row IANA when `updated_at >= 2026-07-15 00:00:19` and `< 2026-09-15` (applied SQL bound; freeze is **2026-08-20 23:19:32 UTC**). Device-offset Instant writes (`#392` .. web fake-`Z` `#1012`) leave. Overnight exact `00:00` ends null (open bound); inverted `:01`–`:59` Instant-ize as next local morning. Instant UTC-crossing `14:00→04:59` leaves. Phase 2 readers format any remaining present clocks through row/Event IANA after deploy.
* **Offer / offer-day Instant-ize.** Hour 10–23 wall `Z` with Event IANA is done in `20260817180000_event_clock_alignment` (skip Event-equal Instants; skip hour 0–9). Inverted hour-0 ends null in `20260817180000_event_clock_alignment`. Hour-0 ends with `end > start` (typically Formula B of a 19:00 wall → `00:00` UTC) stay Instants. ENG-587 is offer **accept** of overnight windows, not this leftover rewrite.
* **Require `Event.timezone`.** Allowed only after filling leftover nulls. Raw prod 2026-08-18 post-stamp still has **853** unlabeled Events because this file is not deployed. After it on the PM dump, **85** Events had no org TZ to copy; the 2026-08-18 org stamps close the mixed-org gap so org copy can fill Soundcheck Team leftovers (TN/CA strings with dummy `0,0` coords — location fill refused them). Not needed for nullable Event clocks.
* **Orgs with no Events.** Leftover empty org timezones are filled with America/Chicago so the column can be required. [ENG-588](https://linear.app/soundcheck-live/issue/ENG-588) replaces those Chicago placeholders with Clerk / PostHog where known.
* **Mobile generated client.** Availability editor + Instant display are in the tree; commit is gated on `npm run generate:api-client` matching services.

Tracked under [ENG-580](https://linear.app/soundcheck-live/issue/ENG-580/event-call-times-utc-as-wall-clock-encoding-vs-orgevent-timezone-docs): [ENG-584](https://linear.app/soundcheck-live/issue/ENG-584) (this availability wave), [ENG-585](https://linear.app/soundcheck-live/issue/ENG-585) (org timezone on create + settings), [ENG-586](https://linear.app/soundcheck-live/issue/ENG-586) (follow-up offer/request IANA columns), [ENG-588](https://linear.app/soundcheck-live/issue/ENG-588) (org TZ backfill for orgs with no Events).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.