> For the complete documentation index, see [llms.txt](https://docs.kula.digital/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.kula.digital/whats-in-your-data/gymmaster.md).

# GymMaster — ontology map

What GymMaster gives Kula Intelligence, what it cannot give, and how its members, classes, attendance, memberships and door access map into the canonical model.

**Source id:** `com.gymmaster` · **Role:** booking + membership system of record · **Access:** Member Portal API (two API keys) + a door-access webhook

GymMaster is the studio's operational system: members, clubs, staff, the class schedule, class attendance, memberships, and — from the day the connector goes live — door access. It is **not** a payments system: there is no API for money at all.

Two limits drive most wrong answers here, and both are about *time*:

* **Door access starts the day we connect.** It arrives by webhook, so there is no history before go-live.
* **Class history is available, but attendee names are frozen.** A roster records the member's name as it was on the class date, so a later name change breaks the link permanently.

## Coverage at a glance

| GymMaster entity                | Canonical home                                                         | Grain                          | History                                       | Freshness                                                   |
| ------------------------------- | ---------------------------------------------------------------------- | ------------------------------ | --------------------------------------------- | ----------------------------------------------------------- |
| `companies` (clubs)             | `people.location` (`kind = 'club'`)                                    | one row per club               | full snapshot                                 | each poll                                                   |
| door / area access points       | `people.location` (`kind = 'access_point'`)                            | one row per door               | derived from access data                      | materialised after load                                     |
| `salesrep` (staff)              | `people.staff`                                                         | one row per staff member       | full snapshot                                 | each poll                                                   |
| `members`                       | `people.member`                                                        | one row per member             | full snapshot — the whole roster each poll    | each poll (only members whose record changed are rewritten) |
| `memberships` (catalogue)       | `commerce.plan`                                                        | one row per membership type    | full snapshot                                 | each poll                                                   |
| `booking/classes/schedule`      | `bookings.class_session` (room in `source_extras`)                     | one row per scheduled class    | **historical — 7-day windows, iterable back** | each poll                                                   |
| per-class `attendees`           | `bookings.attendance`                                                  | one row per attendee per class | matches the class windows pulled              | each poll                                                   |
| door access (webhook)           | `bookings.facility_entry`                                              | one row per access event       | **from go-live only**                         | live                                                        |
| per-member `member_memberships` | `people.member.plan_name` / `current_plan_id` + a `commerce.plan` stub | current plan only              | current state                                 | each poll                                                   |
| per-member `profile`            | `people.member` (fill-only enrich)                                     | one row per member             | current state                                 | each poll                                                   |
| per-member `visits/daily`       | **lands raw only, not yet mapped**                                     | daily counts                   | —                                             | —                                                           |

Anything not in that table, we don't have.

### Door access and the location tree

`people.location` holds **two kinds of row** for a GymMaster studio:

* **Clubs** — `kind = 'club'`, straight from the `companies` pull.
* **Doors and areas** — `kind = 'access_point'`, with `parent_location_id` pointing at the club's `source_external_id`.

The access-point rows are **derived from `bookings.facility_entry`**, not pulled from GymMaster: each distinct door seen in the access log becomes a child location under its club (turnstile, 24hr door, hot studio, pilates door, and so on). So door/area detail rolls up to the club while keeping area-level granularity.

Because they're derived, a door only exists as a location once it has appeared in the access log. A door installed but never used won't be there.

Each `bookings.facility_entry` row carries `door_id`, `door_name`, `status` (`granted` / `denied`), `reason`, `entry_method`, `access_category` (`door_access`, `class_attendance`, `sale`, `appointment`) and `access_type` (`entry`, `exit`, `checkin`, `purchase`) — so it is the full member access log, not only door swipes.

## What we cannot get

Each item says *why*. Most of these are hard limits of GymMaster, not gaps in our implementation.

**Member status history before we started capturing it.** GymMaster reports a member's *current* status only — there is no change log, so nothing recovers the moment a member went on hold or lapsed before we were watching. Kula synthesises the change by detecting it itself, so **from the date capture began** every status, membership-type, plan and suspension change is recorded in `people.member_status_event` — read it with `list_member_status_changes`, or the `status_history` block on `get_member_context`. Each member also carries one `is_baseline = true` origin row recording what we first saw; that is an observation, not a change, so filter it out before counting anything. Establish the capture start before charting a series:

```sql
SELECT min(detected_at) AS capture_started FROM people.member_status_event;
```

**Door access before we connected.** GymMaster delivers access records by **webhook only** — there is no API to read history back. We capture every event from the moment the connector goes live, and **nothing before that point exists**. This is a permanent hole, not a backfill waiting to be run.

Always establish the start date before charting anything door-related:

```sql
SELECT min(occurred_at) AS capture_started, max(occurred_at) AS latest,
       count(*)
FROM bookings.facility_entry
WHERE source = 'com.gymmaster';
```

A "walk-ins per month" chart that runs earlier than `capture_started` will show zeros that look like a collapse in traffic. Start the series at the capture date and say so.

**Transactions, payments and revenue.** GymMaster has **no API for financial data**. Not sales, not payments, not invoices, not outstanding balances. `commerce.sale` and `commerce.payment` are **empty** for a GymMaster studio. Revenue questions must be answered from a connected payments source (Stripe) or not at all.

**Appointments and 1:1 sessions.** GymMaster does not do appointments — there is no such concept in the product. `bookings.appointment` is empty, and always will be.

**Anything about inactive or lapsed members beyond their member row.** GymMaster only mints the per-member portal token used for `visits`, `profile` and `member_memberships` for members holding a **current** membership (`Current`, `Concession Pack`, `Gifted Time`). Members who are `Recently Expired`, `Expired`, `Hold` or cancelled appear in the member list — you get the person and their status — but we **cannot** read their memberships, their visit history, or their profile detail. So:

* Historical membership timelines don't exist. There is no "which plan were they on in March" for anyone.
* Cancellation reasons, end dates and hold periods aren't available.
* A lapsed member's `plan_name` is their **last known** plan, not evidence they still hold it. Their `status` is the authority on whether they're current.

**A member's history once they're deactivated at the source.** GymMaster drops a deactivated member's detail on its own side. Anything not already imported before deactivation is gone permanently — from GymMaster, and therefore from us.

**Granular per-member visit records.** The per-member `visits/daily` endpoint returns daily **counts**, not individual visits. Those rows land raw but have no canonical home yet (*not built* — the mapping is undecided). Granular presence comes from `bookings.attendance` (class bookings) and `bookings.facility_entry` (access events), not from here.

**Class categories.** GymMaster has no category concept at all. We derive `class_session.class_category` from keywords in the class name (so "Reformer Pilates" buckets as Pilates). It is a **derived** field — a helpful grouping, not vendor truth.

**Bulk pagination.** The members, companies, salesrep and plans lists are snapshots with no offset or page parameter — GymMaster silently ignores an offset and returns the same array forever. Each poll asks for the whole list in one response, so completeness rests on GymMaster returning it whole. It has for rosters of tens of thousands of members, and the ingestor logs every pull's row count.

## Class and attendance history — available, with one catch

Unlike door access, **class history is readable**. The schedule endpoint returns a 7-day window per call, and those windows can be iterated backwards, so a studio's past classes and the members who attended them can be loaded well before the connection date.

The catch is how attendance is recorded.

### Attendee names are as-at the class date

GymMaster's per-class attendees endpoint returns **names only** — there is no member id on an attendee — and the name it returns for a historical class is **the name the member had at that time**.

We hash the normalised name into a deterministic placeholder, `unresolved-<hash>`, written to `bookings.attendance.member_id`, with the real name carried in `extras.member_name`. The identity layer reconciles that placeholder to the real `people.member` row when the name matches.

So if a member's name is later changed on their member record — marriage, a correction, a preferred name — **their historical attendance will not link**. The roster still says the old name; the member record says the new one; nothing joins them automatically. The attendance rows are not lost, they just stay unresolved and drop out of any per-member analysis.

Consequences to account for:

* **Joining attendance to members will miss rows.** An attendance row whose `member_id` still starts with `unresolved-` has not been matched. Some proportion of unmatched rows is normal and expected, and it skews **older** — the further back you go, the more name drift has accumulated.
* **Homonyms under-count.** Two distinct members with the same normalised name attending the same class collapse into one attendance row. This is inherent to a names-only feed and is not corrected at ingest.
* **A roster corrected in GymMaster** would otherwise strand the old hash forever. A per-class roster sync deletes attendance rows for a session that are no longer on the vendor's current roster — so attendance for a session reflects the roster as of the last poll, not an append-only log.

Quantify it before quoting per-member attendance figures:

```sql
SELECT count(*) FILTER (WHERE member_id LIKE 'unresolved-%') AS unmatched,
       count(*) AS total
FROM bookings.attendance
WHERE source = 'com.gymmaster';
```

**No-show and late-cancel are not available.** Presence in the attendee list is taken as attended; GymMaster's attendee feed doesn't distinguish booked-but-didn't-show, so `booking_status` is always `attended`.

### Staff names arrive reversed

The class schedule renders the instructor as `"Surname, Firstname"` (often just `"S, Amy"`). We split and normalise it into a natural `First Last` display name. If you see a reversed or single-letter surname on a staff row that came only from the schedule, that's the vendor shape showing through — the direct `salesrep` pull is richer and wins.

### The room is in `source_extras`

A class's `location` in the GymMaster schedule is the **room or studio inside the club** ("Studio A", "Reformer Room"). The club itself is `location_id`. There's no room column: the room lands in `bookings.class_session.source_extras` as `room_name`, so read it as `source_extras->>'room_name'`. It's fill-only, so a poll that arrives without a room keeps the stored one.

GymMaster gives classes no modified clock, so the nightly sweep never refreshes a class that has already landed. Classes loaded before rooms were captured stay without one until their date range is re-pulled. Through `execute_query` the room reads as `[REDACTED]` below full scope. That's the scope rule (`source_extras` is sensitive), not missing data.

## Identity and join keys

| Thing           | GymMaster id                | Canonical                                                      |
| --------------- | --------------------------- | -------------------------------------------------------------- |
| Member          | numeric member id           | `people.member.source_external_id`                             |
| Staff           | `id` or `staffid`           | `people.staff.source_external_id`                              |
| Club            | `id` or `companyid`         | `people.location.source_external_id` (`kind = 'club'`)         |
| Door / area     | door id from the access log | `people.location.source_external_id` (`kind = 'access_point'`) |
| Class session   | booking id                  | `bookings.class_session.source_external_id`                    |
| Membership type | `id` / `membershiptypeid`   | `commerce.plan.source_external_id`                             |
| Class attendee  | **name only** — no id       | `bookings.attendance.member_id` as `unresolved-<hash>`         |

Door access rows carry a real member reference, so **`bookings.facility_entry` joins to members cleanly** — it is the more reliable of the two presence signals where both cover the same period.

## Counting traps

**Two presence signals with different start dates.** `bookings.attendance` goes back as far as the class windows pulled; `bookings.facility_entry` starts at go-live. Any chart combining them will have a step change at the capture date that is an artefact, not a behaviour change. Pick one signal per question, or explicitly window both to the overlap.

**Don't double-count a class visit.** A member attending a booked class can appear in **both** tables — as attendance, and as an access event with `access_category = 'class_attendance'`. Filter facility entries to `access_category = 'door_access'` when you mean "came in without a class booking".

**Not every swipe is a visit.** The access log records refusals (`status = 'denied'`) and, where the doors report them, exits (`access_type = 'exit'`) alongside entries. A visit is an entry the member was let through: `COALESCE(status, 'granted') <> 'denied' AND access_type IS DISTINCT FROM 'exit'` (a NULL in either column counts). `list_at_risk_members` and `get_retention_curve` already apply this. Counting every row inflates visit totals, and a refused swipe makes a member whose membership has lapsed look like they were just in.

**`plan_name` never gets cleared.** The membership refresh uses COALESCE semantics deliberately: it fills a member's current plan but never blanks it. A lapsed member keeps their last-known plan name. Their `status` field is the authority on whether they're current — not the presence of a plan.

**Older status history arrives in monthly batches.** GymMaster's member list carries no last-modified time, so nothing tells us which members changed. The nightly poll fetches the whole roster and rewrites each member whose record differs from the copy we hold, so `status`, contact details and home club trail GymMaster by up to about a day. Before that comparison ran, an existing member's row was rewritten only about every 30 days, so older `people.member_status_event` history comes in bursts: hundreds of status changes in one batch, each with a `detected_at` up to a month after the change happened in GymMaster. The first night the comparison ran brought one last catch-up batch. Treat a burst as a catch-up, not a wave of cancellations, and don't read its `detected_at` as the date the member lapsed. To find the bursts:

```sql
SELECT date_trunc('hour', detected_at) AS detected_hour, count(*) AS status_changes
FROM people.member_status_event
WHERE source = 'com.gymmaster' AND dimension = 'status' AND NOT is_baseline
GROUP BY 1 ORDER BY 2 DESC LIMIT 3;
```

**Class category is derived, and older rows may be NULL.** Sessions ingested before the category rule shipped have `class_category = NULL`. Grouping by category silently drops them.

**Class coverage equals windows run.** A gap in `bookings.class_session` usually means an unrun 7-day window, not a cancelled week — and that gap is also a gap in attendance.

**Read the guarded views.** `bookings.attendance_guarded` and `bookings.class_session_guarded` mask restricted columns by the caller's scope and apply the live-wins filter where a studio also carries rows with `source_is_backfill = true`. Prefer them to the base tables.

## Questions this source can and can't answer

**Can answer well**

* Who are my members, what status are they, which club are they at
* Class schedule, capacity, spots booked, instructor per session — including historically
* Who attended a class (by name, with the matching caveats above)
* Attendance frequency and gaps per member → at-risk / lapsing members
* **Who came in without booking a class** — from the door-access capture date onward
* Traffic by door, by area, by club; denied-access events and their reasons
* Which membership types exist and their prices
* Which plan a **current** member is on right now

**Cannot answer from GymMaster alone**

* Revenue, takings, payments, refunds, outstanding balances *(no API)*
* Door access, walk-ins or access-denied events **before the connector went live** *(webhook-only; no history at the vendor)*
* No-show rate, late-cancel rate *(the attendee list carries no status)*
* 1:1 appointments or PT sessions *(GymMaster doesn't do them)*
* When a member's membership started, ended, or was put on hold *(per-member memberships are current-only, and unreachable for non-current members)*
* Membership history, plan changes, upgrade/downgrade paths *(same)*
* Per-member attendance for anyone whose name changed after the class *(rosters store the name as-at the class date)*
* Anything about a member deactivated in GymMaster before their first import *(destroyed at source)*
* Lifetime value or spend per member *(no money data)*

**Phrase a gap as the vendor's limit and the capture window, not as missing data.** "GymMaster's API doesn't expose door access before we started capturing it, so I can only tell you about door access since we went live on *date*" is correct and useful. "You have no door access data" is misleading, and so is silently starting the chart at zero.

## Recipes — what works well

**Reach for the tool before the SQL.** Each of these encodes rules that hand-written SQL routinely gets wrong on GymMaster data — pause-awareness, the guarded views, the unresolved-name problem, studio-local time.

### Who's lapsing → `list_at_risk_members`

The single highest-value call on a GymMaster studio. It buckets active members by days since last visit (7–13, 14–20, 21–27, 28+), and **a "visit" is an attended class&#x20;*****or*****&#x20;a facility entry** — so once door capture is running, walk-in-only members stop looking lapsed. A denied swipe or an exit swipe is not a visit, so being turned away at the door never resets a member's day count. It also excludes members on a pause, anchors the day count at the pause end for those recently back, and drops drop-in and ClassPass members who aren't expected to return.

Do **not** hand-roll this from `max(occurred_at)` on attendance, and do not use `list_quadrant_members` — quadrants are a relationship-graph enrichment, not a recency measure.

### One member's full picture → `entity_lookup` → `get_member_context`

Resolve the name or email to a canonical id first (`entity_lookup` with `type: member`), then `get_member_context` for connection strength, primary coach and affinity. `get_member_plan_status` gives the raw member record.

Note the GymMaster caveat: if their historical attendance sits under an old name, it won't be on their record. Check for stranded rows before telling an operator a long-standing member has "no history":

```sql
SELECT occurred_at, extras->>'member_name' AS roster_name
FROM bookings.attendance_guarded
WHERE source = 'com.gymmaster'
  AND member_id LIKE 'unresolved-%'
  AND lower(extras->>'member_name') LIKE '%surname%'
ORDER BY occurred_at DESC LIMIT 50;
```

### Schedule performance → `get_class_utilisation`, then `get_time_slot_detail`

`get_class_utilisation` gives the day-of-week × hour heatmap (session count, capacity, booked, mean/min/max fill) over a date range, already excluding cancelled and zero-capacity sessions and bucketing in studio-local time. Then drill into a specific weekday and hour window with `get_time_slot_detail` for the per-class rows.

This is the right answer to "which classes should I cancel" — and it works on GymMaster because it needs only the schedule and bookings, both of which GymMaster gives us well.

### Instructor performance → `get_teacher_performance`

Pass `staff_ref` (a name — resolved server-side) rather than hunting for an id, and set `include_summary: true` so you get the true totals across every matching session rather than just the returned rows.

Watch for the reversed-name artefact: an instructor known only from the class schedule may be stored as `"S, Amy"`. Resolve via `entity_lookup` with `type: staff` if a name doesn't hit.

### Door traffic and walk-ins → SQL on `bookings.facility_entry`

There's no dedicated tool for the access log yet. Always window from the capture start date, and filter the category so a booked class isn't counted as a walk-in:

```sql
-- Walk-ins (door entries with no class booking) by month, since capture began
SELECT date_trunc('month', occurred_at) AS month,
       count(*) AS door_entries,
       count(DISTINCT member_id) AS distinct_members
FROM bookings.facility_entry
WHERE source = 'com.gymmaster'
  AND access_category = 'door_access'
  AND access_type = 'entry'
  AND status = 'granted'
GROUP BY 1 ORDER BY 1;
```

```sql
-- Traffic by door/area, rolled up to its club
SELECT c.name AS club, d.name AS door, count(*) AS entries
FROM bookings.facility_entry f
JOIN people.location d ON d.source = f.source AND d.source_external_id = f.location_id
LEFT JOIN people.location c ON c.source = d.source
                           AND c.source_external_id = d.parent_location_id
WHERE f.source = 'com.gymmaster' AND f.access_category = 'door_access'
GROUP BY 1, 2 ORDER BY 3 DESC;
```

Denied entries are their own signal — `status = 'denied'` with a `reason` often surfaces expired memberships before the member notices.

### Is the data current → `get_system_status`

Before saying anything is missing. It reports each connected source as current / stale / failing with a plain-language "current as of" date.

### Counting real members → `people.member_distinct`

`people.member` is one row per source system. On a GymMaster-only studio that's harmless, but the moment Stripe is also connected the same human has two rows. Count `people.member_distinct`.

### What doesn't work on GymMaster

Don't reach for `get_member_payments`, revenue queries on `commerce.sale`, `get_cac_by_cohort` (needs marketing spend), or `get_retention_curve` (needs recurring charges) — all of them depend on financial data GymMaster has no API for. If the studio also runs Stripe, those tools work off the Stripe side; on GymMaster alone they'll return empty and that is the correct result, not a fault.

## Where this lives in the code

| Concern                                              | Path                                                                                                                      |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Entity catalogue, API keys, collection modes         | `services/ingestors/gymmaster/internal/gmclient/specs.go`                                                                 |
| Pull + per-member walk + current-member gating       | `services/ingestors/gymmaster/internal/gmclient/collect.go`                                                               |
| Fan-out transforms (classes, attendees, memberships) | `services/ingestors/gymmaster/internal/transform/`                                                                        |
| 1:1 SQL projections (members, staff, clubs, plans)   | `services/intelligence/internal/ingest/project/templates_gymmaster.go`                                                    |
| Access-log schema (doors, categories, outcomes)      | `services/intelligence/migrations/postgres/org/300_bookings/009_facility_entry_door.sql`, `011_facility_entry_access.sql` |
| Door/area locations as club children                 | `services/intelligence/migrations/postgres/org/100_people/009_location_door_model.sql`                                    |
| Operator-facing connect guide                        | [GymMaster connector](/your-data-sources/gymmaster.md)                                                                    |
