> 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

**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, modified-since filtered        | each poll               |
| `memberships` (catalogue)       | `commerce.plan`                                                        | one row per membership type    | full snapshot                                 | each poll               |
| `booking/classes/schedule`      | `bookings.class_session`                                               | 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`, `Active`, `Concession Pack`). Members who are `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. Completeness relies on the modified-since window keeping each pull under the vendor's undocumented response cap. The ingestor warns loudly when a pull comes back near that size; a very large single pull is a signal to narrow the window rather than trust the result.

## 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.

## 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".

**`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.

**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. 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)                                                                    |
