Skip to Content
Living documentation — last reviewed 2026-05-28
PlansFizikal ⇄ Taikan two-way class/booking pipe (FreeFit acquisition rails)

Fizikal ⇄ Taikan two-way class/booking pipe (FreeFit acquisition rails)

Status: plan, unbuilt. Blocked on the ADR-0017 validation probe. Date: 2026-09-04 Owner: Saar Decision context: ADR-0017 (vendor choice), ADR-0015 (Arbox precedent, the only live-observed Move behaviour we have) Probe tool: scripts/fizikal-external-v1-probe.mjs

TL;DR

  1. Bookings can be two-way. Classes cannot. External_V1 has classes/registration/add|remove and a roster read, but no class create/update/delete anywhere in its 67 paths. The Move-facing grid is authored by hand in Fizikal, forever. Everything else in this plan is built around that one asymmetry.
  2. The seat is the only write primitive we have, and it is enough. One call — registration/add on a placeholder customer — is simultaneously: capacity mirroring, occurrence soft-cancellation (flood the seats), and capacity reduction (partial flood). Every mitigation below is a different use of that one lever.
  3. Move reads live occupancy. Fizikal’s Move-facing DTO (_Custom.Move.ClassMove) carries maxParticipants and totalParticipants — the same pair as ClassExternal. So a mirrored booking almost certainly moves the number Move renders, which is what makes shared-pool capacity viable and what makes the static allocation option lossy. [INFERENCE — schema-derived; this is probe question Q2]
  4. There are no webhooks. Every webhook-shaped schema in the doc (_Custom.UpGrade360.Webhooks.*, _Custom.Credimatch.Webhook*) is orphaned — referenced by zero paths. The pipe is a poller, and the poll period is the double-booking race window.
  5. Recommended capacity design: full-capacity mirror + aggregator buffer, not ADR-0017’s Option B allocation. Zero dead seats, and dead seats are the thing that costs the studio money in a 5-figure/month channel.
  6. Highest business risk is not technical. It is Q7: if Movement Group only pays the studio for a visit that Fizikal recorded as an entry, and our pipe books seats Fizikal never sees an entry for, the studio loses revenue. A pipe that silently breaks the payout is worse than no pipe. Answer Q7 before P3.
  7. Nothing is built until the probe runs. It needs one thing we do not have: a real API key on a Move-connected Fizikal box.

What changed since ADR-0017 was written

ADR-0017 was written against a summary of the spec. Working from the resolved OpenAPI document (67 paths, 300 schemas, fetched with the guest key) changes six things:

#New factWhy it matters
1registration/remove requires registrationId (ExternalRegistrationRemoveRequest.required = [classDate, classId, customerBranchId, customerCompanyId, customerId, registrationId]), and the roster row (ExternalRegistrationSearchCustomerDTO) does not carry it.Every outbound booking must persist the registrationId Fizikal minted, or it can never be released. To cancel a Move-originated booking we must go: roster → customerIdclasses/upcoming/customerClassesregistrationId → remove. A second call per cancellation, but it means we can cancel Move bookings — ADR-0017 did not know this.
2classDetails/guestView takes only Id + Datesuperseded: that endpoint is not authorised for us. Capacity comes from classes/schedule/customerView, which returns maxParticipants/totalParticipants per occurrence for a whole date window.Q5 is closed anyway: AppTypeId=0 works. But see the confirmed-findings section — AppTypeId=1 silently returns zeroed capacity, so the value is load-bearing, not cosmetic.
3Occurrence identity is the compound key classId + classDate; classId is the recurring template. There is no per-occurrence id in this surface.The manual authoring cost is per weekly template, not per occurrence. A stable weekly grid is authored once and covers every future date. This is the single biggest reduction in the “authored twice” cost.
4purchases/add exists (required: contractStartDate, customerId, priceListItemId, salesmanId). It cannot create a customer or a price-list item, but it can grant an entitlement to an existing customer.If Q1 fails (placeholders can’t book without an entitlement), this is the fallback: create one ₪0 “Taikan Seat” price-list item by hand, then provision/renew placeholder entitlements over the API. Q1 stops being fatal.
5_Custom.Move.* schemas exist in this document but no path references themCustomerMove keyed on identityCard, RegistrationAddMoveRequest{classId, classDate, moveSourceId, customer}, RegistrationRemoveMoveRequest{customerIdentityCard, …, forceCancellation}, ScheduleViewMoveResponse, EntryMoveRequest.Two things. (a) It confirms exactly what Move sees and how it books — inline customer upsert keyed on national ID, source-tagged with moveSourceId. (b) A Move-native surface exists somewhere. Asking Fizikal support “can our tenant use your Move API?” is a customer support question, not a partnership ask, so it is posture-safe under ADR-0017 D2. Worth one email.
6X-API-key is the live auth channel, and ?apikey= is not. Verified from the workstation just now: ?apikey=fizikalguest21401 "Missing Api Key"; X-API-key: fizikalguest21401 "Unauthorized External ServerKey". The error moves from missing to unauthorized, i.e. the header was read and the key rejected.Q6 is half-answered without any credential. Remaining unknown: whether X-Timestamp/X-Signature are additionally required for a valid key. Also note we were not network-blocked from a laptop — the allowlist is enforced at or after key validation, so a valid key from the wrong IP will look like an auth failure, not a connection failure. Budget for that confusion.

Unchanged and still true: no class create/update, no customer create, no roster delta feed, no seat hold, no idempotency key, no documented rate limit, no aggregator discriminator in External_V1.

Live findings, 2026-09-04 — real credentials, real errors

Fizikal’s integrator issued a test club and a key, and manually authorised four endpoints. Everything below was observed against api.fizikal.co.il, not read from a spec.

Tenant: OrganizationId=1, CompanyId=2, BranchId=7. Test customer 1962. Key is sent as the x-API-key header and lives only in Railway variables — never in this repo.

Authorised surface (all we can call today):

EndpointGives us
GET customers/SearchActiveCustomers?Phone=phone → customerId
GET classes/schedule/customerView (AppTypeId=0)schedule and capacity (maxParticipants/totalParticipants) and registrationId for that customer
POST classes/registration/addoccupy a seat
POST classes/registration/removerelease a seat (needs registrationId)

classes/registration/search is NOT authorised — the roster feed, i.e. the entire inbound half of the pipe. Without it we cannot see who booked from FreeFit; customerView is per-customer and needs a CustomerId we do not have for a Move booker. This is the single blocking ask of the next email to them, and it re-sequences the build: the outbound half (protect member seats by mirroring Taikan bookings into Fizikal) is buildable now, the inbound half is not.

Observed facts, superseding earlier inferences:

  1. Q6 answered. x-API-key alone is the live scheme — no timestamp, no signature needed. Proof: with only that header the request passes key validation and fails on IP (below); with ?apikey= it fails as Missing Api Key.
  2. The IP allowlist is real, and the error names our own IP: 401 "Unauthorized External ClientIp 46.121.130.64". That is a gift — Railway execution is verifiable in one call, and a future allowlist drift diagnoses itself.
  3. An HMAC path also exists and is enforced when its headers are present. Sending X-Timestamp in the wrong format returns 400 "Invalid X-Timestamp format. Expected yyyy-MM-ddTHH:mm:ss.fffZ in UTC"; sending it correctly returns 401 "Invalid X-Signature". So there is a signature-authenticated mode — plausibly not IP-bound, which would remove our whole Railway-only constraint. The algorithm and the signing secret are undocumented. Ask them.
  4. AppTypeId = 0 is valid (their own example uses it). The Q5 unknown is closed for the endpoints we hold.
  5. classDate is date-only (2026-08-23) in their own examples, for both the read and both writes. So an occurrence key is classId + calendar date — no time component — which means the entity map must assume one occurrence per classId per day until proven otherwise. The probe checks this explicitly (schedule.duplicateClassIdsPerDay); if a classId ever repeats within a day, that assumption and the mapping both change.
  6. registrationId is a large int (974448028 in their example) minted by Fizikal, required for registration/remove, and readable back from customerView — so a seat we lose track of is still recoverable, per customer.
  7. There is no Move-native surface for us. Confirmed with the owner: what we see is what they have. The _Custom.Move namespace stays interesting only as evidence of what Move reads (maxParticipants + totalParticipants).

Decisions settled by the owner on the back of this (do not relitigate):

  • B = 0. No seats reserved for Move. Org members always win.
  • No identity matching in v1. Move bookers are external attendees, displayed as name + EXTERNAL.
  • Fizikal is strictly a FreeFit surface. Everything is managed in Taikan.

Confirmed on the box, 2026-09-04, executed from Railway

Ran through railway ssh --service @taikan/api. Booked and released a real seat on the test club. Everything below is an observed response, not an inference.

Railway egress — the whole picture.

ServiceStatic outbound IPsObserved egressFizikal
@taikan/apienabled — 162.220.232.251, 152.55.177.192, 152.55.177.193 (sfo, “Shared”)152.55.177.192accepted
@taikan/api-schedulerenabled 2026-09-04 — 162.220.232.251, 152.55.176.240, 152.55.177.192still 54.219.27.66a redeploy is required before the static pool takes effectpending

Both services must be whitelisted, and this is not a preference. CRONS_ENABLED gates @Cron methods only (apps/api/src/common/crons-enabled.ts:9); it does not gate BullMQ @Processors, and both services build the same image and load the same modules. A fizikal processor therefore has three consumers on one Redis queue — api ×2 replicas plus scheduler ×1 — so a partial whitelist fails roughly a third of jobs with Unauthorized External ClientIp, per job, intermittently. The alternative (gate the processor so it only loads on api) introduces a second env-gating mechanism beside CRONS_ENABLED that every future queue must remember; whitelisting is cheaper and more honest.

The union to give Fizikal is four addresses — the pools overlap but are not identical: 162.220.232.251, 152.55.176.240, 152.55.177.192, 152.55.177.193. Type is Shared, i.e. not exclusively ours; the x-API-key is still required, so this is acceptable, but it is the honest answer if they ask.

Q1 — GO. registration/add for customer 1962 on class 9074 @ 2026-09-08success: true, registrationId: 977328741, customerStatusName: "רשום לכיתה", action: {text: "ביטול", name: "RemoveRegistration"}. No entitlement error of any kind. A stub-seat pool is viable and purchases/add is not needed.

Q2 — GO in its decisive form. totalParticipants went 0 → 1, confirmed by an independent follow-up read, and back to 0 after removal. That is the exact field Fizikal hands Move in _Custom.Move.ClassMove. Full end-to-end proof still needs a Move-connected club, but the counter Move reads does move.

A new blindspot, and it would have burned us: age validation. Two other classes rejected the same customer with "תאריך לידה לא תקין" (“invalid date of birth”) — both carry age limits (fromAge: 8, toAge: 5 on one, toAge: 1 on another). So stub seat customers must have a date of birth that satisfies every class they will ever be booked into. A stub pool created carelessly will fail exactly on the age-gated classes and nowhere else, which is the worst possible failure distribution. Mitigation: give stubs a DOB around 25–30 years old, and have the drift detector flag any class whose fromAge/toAge excludes the pool.

Error taxonomy — this is what the adapter must branch on. Note the first row: a failure arrives as HTTP 200. Never trust the HTTP status; branch on success.

ConditionHTTPEnvelopeHow to detect
Already registered (duplicate add)200success: false, message: "ביטול" (it returns the action label, not an error)data.class.registrationId is present and customerStatusName = "רשום לכיתה" → treat as success, adopt that registrationId
Age limit / bad DOB200success: false, message: "תאריך לידה לא תקין"park the op; it will never succeed on retry
Unknown class or wrong date404exception: {message: "Class Not Found", entity: "Class"}the occurrence key is wrong — re-resolve from customerView
Stale / wrong registrationId, or already removed404exception: {message: "Entrance Not Found", entity: "Entrance"}remove is not idempotent — treat this 404 as “already released” and close the op
Endpoint not in our grant401exception: {message: "Unauthorized External API (4)"}configuration, not runtime — surface to staff
IP not whitelisted401exception: {message: "Unauthorized External ClientIp <ip>"}names the offending address; alert with the IP in the message

Capacity-full is the one error shape still unobserved — the test club gave us a single customer, so a class cannot be filled past one seat. It needs either a second test customer or a stub pool.

schedule/customerView has hard server-side limits, and they shape the poller:

  • FromDate may not precede Sunday of the current week400 "Minimum FromDate is sunday this week". There is no historical read at all, so reconciliation can only ever look forward. Anything we failed to observe before Sunday is lost.
  • Range is capped at 7 days400 "Date range must be up to 7 days" (an 8-day span is rejected). A 14-day horizon means two calls.
  • AppTypeId=0 (or omitted) returns real capacity. AppTypeId=1 returned a different class count with maxParticipants: 0 on every row — a wrong value silently zeroes capacity instead of erroring. Pin it to 0 and assert maxParticipants > 0 on ingest.
  • classDate accepts both 2026-09-08 and 2026-09-08T08:00:00; both booked successfully.
  • Response dates are naive local ("2026-09-08T00:00:00"), startTime/duration are "HH:MM:SS".
  • totalParticipants in a write response is unreliable (one add returned the post-increment value, another returned the pre-increment one). Always re-read after a write.
  • The test club carries 7–78 classes per day, and action.text doubles as a state machine: הרשמה (bookable), ביטול (I’m booked), סגור להרשמה (closed), תזכורת (reminder-only).

Scope re-confirmed live: classes/registration/search401 Unauthorized External API (4). So does waitinglist/add, classes/search, guestView, entrances/search, pricelist/search, employees/search, branch/details, customer/personaldetails, customer/purchases, customers/search. Our grant really is those four endpoints.

Feasibility

CapabilityVerdictMechanism
See FreeFit bookings on the Taikan rosterYespoll classes/registration/search (FromDate/ToDate), diff client-side
Stop Move overselling seats Taikan already soldYes, two waysinterim: cap maxParticipants at authoring time (manual, static). Target: mirror Taikan occupancy as placeholder seat occupation
Stop Taikan overselling seats Move already soldYesinbound roster seats consume Taikan capacity like any other seat
Cancel a Move booking from TaikanYesupcoming/customerClassesregistrationIdregistration/remove
Push the Taikan schedule into FizikalNono class create/update endpoint. Manual authoring + drift detection is the ceiling
Cancel/move a single occurrence via APINo, but mitigableflood remaining seats with placeholders → occurrence is closed to Move. Time changes are unmitigable
Event-driven syncNono webhooks in the reachable surface. Poll only
Provision a FreeFit user as a Taikan member automaticallyNo (and out of scope)no customer create in Fizikal; ADR-0017 D5 keeps members/payments off this pipe entirely
Write attendance back to FizikalUnclearcustomer/entry is a facility check-in, not class-scoped; isArrived may be read-only to us (Q7)
Real-time (sub-minute) consistencyNopoll cadence bounds it. Design for eventual consistency + a buffer

Architecture

Two loops and one ledger. Nothing else.

Inbound loop (the irreducible core)

  • Queue fizikal-roster-poll, registered in a new apps/api/src/fizikal module. Enqueued by a @Cron on @taikan/api-scheduler (CRONS_ENABLED=true, 1 replica — the automation-scheduler.service.ts backstop pattern), processed on @taikan/api, because that is the only service whose egress IP Fizikal accepts. BullMQ processors are safe across the api tier’s 2 replicas; a bare @Cron there would double-fire (docs/runbooks/api-web-scheduler-split.md).
  • Cadence, tiered because there is no delta filter and no pagination on the roster: today+tomorrow every 2–3 min; T+2..T+14 every 30 min; full 14-day sweep hourly as a reconciler. Poll per-day, not per-window — one unbounded FromDate..ToDate response is the documented scale risk.
  • Diff, not upsert. Hash each day’s payload to skip unchanged days cheaply. branch/hotupdates is a candidate “did anything change” pre-check — probe whether it ever reports class changes before relying on it.
  • Every inbound seat becomes a Taikan seat. A Move attendee is an external attendee occupying a seat, never a member (ADR-0017 D4, ADR-0015 identity rules).
  • Attendance flows inbound only: isArrived on the roster row, with entrances/search (carries classId) as a cross-check.

Outbound loop (capacity mirror)

  • Transactional outbox. BookingsService.book()/cancel() insert an op row into external_sync_ops inside the existing SERIALIZABLE transaction (apps/api/src/bookings/bookings.service.ts:528). No HTTP inside the tx. There is no booking created/cancelled event to hook: apps/api/src/bookings/booking-events.ts defines booking.no_show and booking.attended only (attendance, consumed by automations), and nothing in apps/api/src/bookings emits anything else. The outbox insert is the integration point — do not build an event bus for this.
  • Dispatcher = per-aggregate FIFO with aggregate_key = <connectionId>:<externalClassId>:<local date> (fizikal-occurrence.ts), exactly the automation-engine.service.ts pattern: an op is eligible only when no earlier op for the same key is unfinished. Ordering per occurrence is the invariant that keeps seat counts sane; global ordering is not needed. The key is connection-scoped because Fizikal class ids are club-scoped ints and would otherwise collide across clubs.
  • Ops are two verbs: occupy_seat (registration/add with a free stub customerId) and release_seat (registration/remove with the persisted registrationId). No member identity, no membership provisioning, no PII leaves Taikan.
  • Circuit breaker per connection (automation-circuit-breaker.service.ts): trip → connection paused, ops park, Taikan keeps working, Move visibility degrades. That degradation order is non-negotiable — Taikan is the system of record.
  • Retry safety. No idempotency key exists, so it is enforced on our side in two places: a partial unique index on (booking_id, op_type) means at most one occupy and one release per booking ever, and already_registered / already_released are classified as successes (fizikal-outcome.ts) so a replay adopts the existing seat instead of creating a second one. Only transient is retryable; everything else parks.

Build state (2026-09-04)

Landed, typechecked, linted, 17 unit tests green:

FileWhat
libs/db/src/lib/schema/external-sync.tsexternal_entity_map, external_sync_ops — provider-scoped, org-scoped, with the (booking_id, op_type) partial unique and a CHECK that a release_seat op cannot exist without an external_ref
libs/db/src/lib/schema/enums.tsintegration_provider += 'fizikal'; external_entity_type, external_sync_op_type, external_sync_op_status
libs/db/drizzle/0123_little_prism.sqlapplied and verified on the local dev database; NOT applied to productiondb:migrate there needs owner approval. Constraints exercised against real Postgres rather than reviewed: a release_seat op without an external_ref is rejected; a class_template map row without a taikan_id is rejected; two ops inserted in one transaction share created_at to the microsecond but get distinct seq values (the reason seq exists); two orphan ops with a NULL booking_id both insert, so the partial unique does not block reconciler-generated releases
apps/api/src/fizikal/fizikal.types.tshand-written wire types; generated ones would hide the spec-vs-reality gaps
apps/api/src/fizikal/fizikal-outcome.tsthe error taxonomy as code — pure, unit-testable, no network
apps/api/src/fizikal/fizikal.client.tsx-API-key header auth, envelope-first, returns classified outcomes and never throws for a vendor failure; retry policy belongs to the dispatcher, which can see attempt counts and park
apps/api/src/fizikal/fizikal-occurrence.ts(classId, club-local date) addressing + the aggregate key
*.unit.spec.tsevery fixture is a response the vendor actually returned; DST-boundary cases for the local-date rule
libs/shared/.../feature-flags.tsFeatureFlags.FIZIKAL_FREEFIT_SYNC — one key for the whole integration, shared by api/web/admin. Enforced in exactly one place: FizikalConnectionService.resolve() returns null unless the flag is explicitly true, and every surface reaches the vendor only through a connection resolved there. No finer key on purpose — half-enabling the pipe is the state that overbooks a class
apps/api/src/fizikal/fizikal-occupancy.service.tsthe count poll (*/3 * * * *) — writes external_session_occupancy per session, and BookingsService subtracts aggregatorHeldSeats inside the existing capacity check. This is what stops Taikan overselling a class the aggregator filled
apps/api/src/fizikal/fizikal-drift.service.tsthe schedule drift cron (20 6,18 * * *) — compares the two grids and emits one instruction per divergence: missing in vendor, missing in Taikan, start-time mismatch, capacity above/below, age limits excluding the seat pool. Read-only against the vendor, so it is safe to run for a club long before the mirror is switched on
libs/db/drizzle/0124_brave_morgan_stark.sqlexternal_session_occupancy, applied and verified on the worktree database

All of this lives on feat/fizikal-freefit-rails in worktrees/fizikal-freefit, with its own database (taikan_dev_fizikal) per CLAUDE.md — never in the root checkout, and never sharing taikan_dev.

Controls — three independent gates, and why there are three

Nothing reaches Fizikal unless all three pass. They are separate because they answer to different people on different timescales.

GateScopeWho uses itWhy it is not one of the others
FIZIKAL_ENABLED (env)whole subsystem, every clubon-call, during an incidentInstant, and depends on nothing external. The flag needs PostHog reachable and dashboard access; a kill switch cannot
FIZIKAL_CRONS_ENABLED (env)the three scheduled loops onlyon-callThe timers are what hammer a vendor having a bad day; the mirror only moves when a member books. Silencing the timers while leaving member-driven mirroring alive is the difference between degrading and switching off. Defaults to FIZIKAL_ENABLED, so a normal deploy sets one variable
FeatureFlags.FIZIKAL_FREEFIT_SYNC (PostHog)one clubproduct, during rolloutA flag flip is a decision with an audit trail; a kill switch is a reflex. Conflating them means the fastest way to stop the integration is also the way to lose the record of who enabled it
config.toggles.{mirror,occupancyPoll,driftDetection}one club, one loopsupport, per clubLets a club be brought up read-only (drift only) or paused mid-incident without touching anyone else. mirror: false, occupancyPoll: true is the safe half; the reverse is the dangerous one and the UI says so

Enforcement is one function: FizikalConnectionService.resolve() checks the kill switch, then the flag, then loads the connection, and every surface reaches the vendor only through what it returns. resolveForAdmin() deliberately skips the first two — a club is set up and its credentials tested before the flag goes on — and is named so that its use on a member’s path would be obvious.

Observability — built because this subsystem fails silently

Three properties make ordinary error reporting useless here, and each has a specific answer:

PropertyWhy it hides failureAnswer
Almost nothing throwsThe client returns classified outcomes and the dispatcher parks poison ops, so a club whose every write parks looks like a club with no bookingsAn event per call carrying the outcome kind, so the question “what fraction of this club’s writes are parking, since when” is a rate over a dimension rather than an archaeology exercise
The dangerous state is absenceA stalled occupancy poll raises nothing; capacity quietly stops accounting for aggregator seats and the first symptom is someone turned away at the door days laterA 15-minute sweep. Every tracked session stale at once means the poller stopped, not that one class drifted — that is an error to Sentry, with the count and the staleness threshold attached
The vendor answers HTTP 200 for failuresHTTP-level monitoring sees a perfectly healthy integration while nothing worksOutcome classification happens before anything is recorded, so the signal is kind, never the status code

Also: ip_forbidden and endpoint_forbidden escalate to Sentry as warnings rather than being logged and forgotten. Both are configuration faults with one specific human action attached (whitelist an address, request an endpoint), and both would otherwise repeat forever in silence, because the ops that hit them park and nothing retries.

Per-call Sentry spans are op: 'http.client' named by endpoint, with the org id as an attribute and never the URL — every Fizikal URL carries the tenant triple in its query string and the key travels in a header Sentry would otherwise attach verbatim.

Setup surface

GET/PUT/DELETE /organizations/:orgId/integrations/fizikal plus POST /test, GET /vendor-classes, GET /class-types, PUT /mappings, GET /drift. Owner/admin only, org-scoped, and the key is never returned — reads carry a fingerprint (length plus first and last character), which is all a settings screen needs in order to confirm which key is installed and catch a truncated paste. POST /test reads the schedule as the first stub customer, because that one call proves everything setup can get wrong at once: the key, the allowlisted IP, the tenant triple, and that the stub exists over there. On an allowlist rejection it returns the address Fizikal named, which is the only value the fix needs.

Note what has no route: enabling the integration. That is the flag and the env switch, deliberately outside the product surface, so a club cannot switch on a pipe that writes into a third party by clicking a toggle in its own settings.

UI: apps/web/src/components/settings/fizikal-connect.tsx, on the existing integrations settings page. It renders the global switch, the per-org flag and the connection status as three independently legible lines, because “configured but not live” must never look like an error, and a half-enabled pipe must never look healthy.

The bookings table change is deliberately NOT in this migration. The polymorphic seat ledger (nullable membership_id + external_attendee_id) exists only to hold inbound Move attendees, and inbound is blocked on Fizikal granting classes/registration/search. Touching the hottest table in the schema months before the feature that needs it would be all of the risk and none of the value. It moves with P3.

Callsite audit for that future change (bookings.membershipId, 34 sites). The conclusion is that the risk is concentrated and small:

SiteBehaviour with a NULL membership_idAction for P3
bookings.service.ts capacity counts (:449, :487)count by class_session_id only → external seats correctly consume capacitynone — this is the whole point
every member-scoped query (eq(bookings.membershipId, …), ~28 sites incl. quotas, overlap, entitlement sweep, automations)a NULL never matches an equality predicate → invisible, which is correctnone
memberships.service.ts:761, notification-scheduler.service.ts:348innerJoin(memberships) silently drops external rowsnone — externals are not members, and their attendance comes from the vendor roster
class-sessions.service.ts:1886 (session roster)with: { membership: { with: { user: true } } } yields membership: nullmust handle — render name + EXTERNAL badge
class-sessions.service.ts:977, :1146, :1455 (bulk cancel + notify)fetches membership→user to notify; external rows have no usermust handle — cancel the seat, skip the email, enqueue a release_seat op
booking-enforcement.util.ts:239 (waitlist promotion)reads candidate.membershipId on promotionsafe only while external seats are never waitlisted — enforce in the ingest path, since the vendor’s waitlist endpoints are not granted to us anyway
bookings.service.ts:1233/:1289 (attendance analytics)counts attended/no_show org-wide → would include FreeFit visitorsdecide in P3. A FreeFit visitor is real attendance, so including them is probably correct — but it must be a decision, not an accident
insights.service.ts:1004counts waitlisted onlyunaffected given the rule above

Capacity policy — recommendation

A: full-capacity mirror (recommended)B: static allocation (ADR-0017 fallback)
Fizikal maxParticipantsthe real capacitydeliberately capped (e.g. 5 of 20)
Outbound writesyes, one per Taikan bookingnone
Dead seatsnoneyes, and permanent — no class-update endpoint means unsold Move seats can never be handed back
Gated onQ1 (entitlement-free registration), Q2 (mirror visible to Move)Q4 (Move honours the cap)
Failure modeoverbooking inside the race windowlost revenue, silently, forever

Take A. B is the interim safety for P2 (zero code, manual cap at authoring time) and the permanent fallback if Q2 comes back NO.

The race window and how it is closed. Fizikal offers no seat hold. Two mitigations, both cheap:

  1. Aggregator buffer Bdecided 2026-09-04: B = 0. Org members always have priority. No seat is ever held back for Move. Consequence, accepted explicitly: when Move sells the last seat between two polls, the mirror op fails and the class is physically overbooked. The op parks and staff are notified; we do not auto-cancel a FreeFit attendee (that risks the studio’s standing on the channel that is the whole point of this build). Staff can cancel it deliberately — upcoming/customerClassesregistrationIdregistration/remove.
  2. Mirror-first on the tail stays, and with B = 0 it is the only protection left. When Fizikal-visible remaining ≤ k (default 2), the mirror runs synchronously before the Taikan booking commits: registration/add → then open the SERIALIZABLE tx and confirm; if Fizikal says full, the member is waitlisted instead of overbooked; if the tx then fails, registration/remove compensates. A saga on the last two seats only.

Correction 2026-09-04: the outbound mirror alone cannot remove the caps

An earlier version of this plan said P2 (the outbound mirror) upgrades a club from static allocation to full shared capacity. That is wrong, and the error matters, so it is recorded rather than quietly edited.

Mirroring protects exactly one direction. It stops the aggregator selling a seat Taikan already sold, because every Taikan booking occupies a vendor seat. It does nothing about the opposite direction: Taikan has no idea the aggregator sold anything, so it keeps selling its full capacity, the mirror eventually tries to occupy a seat that no longer exists, and the class is physically overbooked. That is the failure the coach actually feels — someone arrives and there is no room.

So with outbound only, the caps must stay, and dead seats stay with them.

Two-way alignment by count — and it needs no new grant

The missing direction does not require the roster endpoint. classes/schedule/customerView — which we already hold — returns maxParticipants and totalParticipants per occurrence. Our own mirrored seats are known exactly, from the outbox. Therefore:

aggregatorHeldSeats = totalParticipants − ourMirroredSeats effectiveTaikanCapacity = class_sessions.capacity − aggregatorHeldSeats

That closes the loop in both directions with the four endpoints Fizikal has already authorised:

Aggregator overselling Taikan’s seatsTaikan overselling the aggregator’s seats
Static allocationprevented (hard cap)prevented (cap Taikan too) — at the price of dead seats on both sides
Outbound mirror onlypreventednot prevented
Outbound mirror + count pollpreventedprevented, and no dead seats

What the count does not give us is who. Names on the coach’s roster still need classes/registration/search. This is the same “who vs count” fork the Arbox bridge hit (TKN-264), and the same answer: count is enough for correctness, identity is a separate feature.

Cost of the count approach, stated plainly:

  • Freshness. Capacity is as fresh as the poll (target 1–3 min for today and tomorrow). Between polls the aggregator can sell a seat Taikan still believes it has. With B = 0 that race resolves as a physical overbook, and staff are notified — the same accepted trade already recorded above.
  • A member can see fewer seats than the room holds. That is correct, not a bug: the missing seats are sold.
  • It moves during the day. A class can show 8 free at 09:00 and 6 at 11:00 with no Taikan booking in between. Any capacity display that caches aggressively will look broken.

Implementation shape (small, on top of what is already built): the poller writes aggregator_held_seats per class session; BookingsService subtracts it inside the existing capacity check; the reconciler already reads exactly this endpoint for its own purposes, so the vendor call is shared rather than doubled.

End state, assuming every requested endpoint is granted

What the club experiences once the seven access requests land, and — more usefully — what is still manual, because that residue is the only thing worth disclosing to a pilot.

Becomes automatic and invisible:

LoopMechanism
Member books in Taikan → seat disappears from FreeFitoutbound mirror (built)
FreeFit books → seat disappears from Taikanroster poll (registration/search), or the count poll where identity is not needed
Either side cancels → seat returns on the othermirror release / roster diff
Coach roster shows FreeFit attendees by nameregistration/search rows → external attendees, EXTERNAL badge
AttendanceisArrived inbound; entrances/search as a cross-check
Waitlist`waitinglist/add
Capacity reads without a stub customerschedule/guestView
One-off occurrence cancellation (holiday, coach sick)seat-flooding — occupy every remaining seat so FreeFit cannot sell it. No class-update endpoint needed, no human in Fizikal
Capacity reduction on a single classpartial flooding, same lever
Reference-data mapping (locations, levels, groups, instructors)classes/search + the lookup endpoints, name-matched with a confirm step

Permanently manual — and this is the whole list:

  1. Adding or removing a class from the weekly timetable. No class-create endpoint exists anywhere in the 67-path surface, so it is not on the request list and never will be. Authored once per recurring template, not per occurrence.
  2. Changing a class’s time. Same reason. Unmitigable.
  3. Changing the instructor shown to FreeFit users. Cosmetic, low stakes, still a human.

Note what is not on that list. Single-day cancellations and capacity reductions — the frequent changes — are automatable through seat occupation. If the Fizikal class is authored at the room’s real capacity, every reduction is expressible as flooding, so capacity edits leave the manual list entirely. What remains is structural timetable change, which for a box is a seasonal event, not a daily one.

Irreducible, regardless of grants:

  • Poll latency. No webhooks exist in the reachable surface. Capacity is fresh to within the poll interval; a simultaneous last-seat booking on both sides can still overbook, and with B = 0 that resolves in the member’s favour with staff notified.
  • Forward-only repair. FromDate cannot precede Sunday of the current week (the request to lift this was deliberately not sent — it discloses the sync engine and a vendor does not loosen an API constraint for one customer). Drift older than the current week is unobservable, which is why the outbox is transactional rather than best-effort.
  • Crediting. Whether the club is paid per registration or per recorded entry is Q7 and is not an API question. customer/entry is grantable but is a facility check-in, not class-scoped, so it may not credit a specific class even when we can call it.

Identity and matching — deferred out of v1

Decided 2026-09-04: no member matching. Every inbound Fizikal roster row becomes an external attendee, full stop. No phone lookup, no national-ID resolution, no ambiguity queue, no customer/personaldetails second hop. If a FreeFit booker happens to also be a Taikan member they appear twice, once per channel — which is exactly what Move/FreeFit does to the studio anyway, and it is honest about which channel paid for the seat.

This deletes a whole subsystem from the build (ADR-0015’s match/park rules, the encrypted national-ID handling, the resolution UI) and can be added later without migrating anything: external_attendees.matched_membership_id stays nullable and unused.

What the coach sees: firstName lastName + an EXTERNAL badge on the roster. Nothing else about that person is stored or shown.

Data model (all new; provider-scoped, not vendor-named)

ADR-0017’s cost item — generalize before it ships — is free right now: arbox_entity_map/arbox_sync_ops were never built. Verified: no such tables in libs/db/src/lib/schema/, no such migration in libs/db/drizzle/ (123 migrations, latest 0122_lead_follow_ups.sql). This is greenfield.

ObjectShapeNotes
integration_provider enum+ 'fizikal'libs/db/src/lib/schema/enums.ts
integration_connections rowreuse as-isencrypted_credentials = {apiKey, apiSecret?} via CredentialEncryptionService (AES-256-GCM). config = {organizationId, companyId, branchId, appTypeId?, placeholderCustomerIds[], bufferSeats, mirrorFirstThreshold}
external_entity_map(id, organization_id, provider, entity_type, taikan_id, external_id, external_key)entity_type ∈ class_template, location, instructor, level, group, occurrence, customer. external_key holds the `classId
external_sync_ops(id, organization_id, provider, aggregate_key, op, payload, status, attempts, next_attempt_at, last_error, parked_reason, external_ref)external_ref persists registrationId — without it a seat is unrevokable. Indexed on (aggregate_key, status) for the FIFO dispatcher
external_attendees(id, organization_id, provider, external_customer_id, first_name, last_name, phone, matched_membership_id?)Unique (organization_id, provider, external_customer_id). One row per distinct Fizikal customerId, created on first sight and never reused. No user row, no membership, no subscription, no lifecycle. matched_membership_id exists but stays null in v1
bookings (change)membership_id → nullable, + external_attendee_id, CHECK exactly-one-of, replace unique(class_session_id, membership_id) with two partial uniquesOne seat ledger. Capacity, waitlist promotion, roster queries and quota logic keep working unchanged. Audit cost is bounded: all 34 bookings.membershipId sites filter by a specific membership id, so a NULL never matches them; only aggregate readers (insights.service.ts, coach roster lists that innerJoin memberships) need changing. Enumerate with lsp references, not grep

Rejected alternative 1: a separate class_session_external_seats table. Less invasive, but it forks capacity arithmetic into two places, and capacity being one number is the entire point of this feature.

Rejected alternative 2 (and this is the one to argue about, because it looks cheaper): a pool of pre-made “EXTERNAL” Taikan memberships, mapped to the Fizikal stub customers, with the mapping kept in R2 for easy editing. Rejected on five grounds:

  1. memberships.userId is NOT NULL and FKs to users (libs/db/src/lib/schema/memberships.ts:29), with unique(userId, organizationId). Every pooled membership therefore needs a fake users row — in the table Clerk owns and syncs. That poisons the identity spine of the product to save one migration.
  2. Reuse destroys history. Bookings are permanent records (no_show/attended, insights, booking.attended events). Rebadging a pooled row per booking means last month’s attendance shows whoever occupies that slot today.
  3. Pool exhaustion is silent data loss on a read path. unique(class_session_id, membership_id) means one pooled member holds at most one seat per session, so the pool must be ≥ the largest class; overflow means we simply fail to record a real FreeFit booking.
  4. Fake memberships leak into every aggregate: insights.service.ts member counts, booking-entitlement-sweep.service.ts, quota-usage.service.ts, payments/recurring-charge.service.ts, member lists, exports. Each becomes a permanent “unless it’s an EXTERNAL row” special case, scattered across 34 join sites.
  5. R2 is the wrong plane for a relational mapping — no FK, no transaction, no org-scoped enforcement (ADR-0004 is code-enforced), no audit, and it would be read on the booking hot path. integration_connections.config (jsonb, org-scoped, sibling to the encrypted key, PATCHable from the admin app) gives identical edit-without-deploy ergonomics with none of that. The Fizikal-side stub customer ids do belong there — that pool is real and correct; it is just a Fizikal-side artefact, not a Taikan identity.

Net: one migration (two columns, one small table, two partial indexes) buys a clean seat ledger. The pool “saves” that migration and charges interest forever.

Cross-cutting

  • Feature flag fizikal-freefit-sync, PostHog, keyed on orgId, default OFF, fail-closed — EventTrackingService.isFeatureEnabled, reference consumer WebhookEnforcementService. Undefined (eval failure) must mean “off”, i.e. Taikan-only behaviour.
  • Multi-org isolation (ADR-0004): every new table carries an indexed organization_id; enforced in code, not RLS. Highest-risk convention in this build — a cross-org seat leak is a data breach with a competitor in the loop.
  • Audit (ADR-0018): sync-originated booking mutations are actor_type='system', category operations.
  • i18n: new keys in all three locales, in both dictionary locations (libs/shared/src/lib/i18n/dictionaries/{en,he,ru}.json and apps/web/src/i18n/dictionaries/). Precedent namespace: importExport.arbox.*. No hardcoded English, including in operator-facing drift/issue messages.
  • Rate limiting: undocumented upstream, so a conservative per-queue limiter (start ~5 rps) plus the breaker.

Gap register — what will break, and what we do about it

Ordered by how much damage the gap does if we ignore it.

#GapBreaksMitigationResidual risk
G1Move-visit payout mechanics are unknown (Q7). If Movement Group pays the studio only for visits Fizikal recorded as entries, seats we book without an entry may not be paid.The business case, silentlyAnswer Q7 with Movement Group and Fizikal before P3. If entries are required, push customer/entry on Taikan-side check-in of a matched Move attendeecustomer/entry is not class-scoped, so it may not credit a specific class. Unresolvable from our side
G2No class createSchedule authored twice, foreverAuthor recurring weekly templates in Fizikal once (classId+classDate means one template covers all dates). Build a drift detector: poll classes/search + occurrence reads, diff against the Taikan grid, surface a concrete checklist (“add Tue 18:00 HIIT to Fizikal”, “Fizikal capacity 12 ≠ Taikan 15”). Ask Fizikal support for a CRM bulk/CSV class import — a paying-customer request, posture-safeEvery grid change needs a human. Drift detector makes it visible, not automatic
G3No class update/delete — can’t cancel one occurrence, change time, or change capacityHoliday closures, coach illness, capacity editsSeat flooding: occupy all remaining seats with placeholders → occurrence closed to Move without any class-update endpoint. Partial flood = capacity reduction. Already-booked Move users can be cancelled: roster → customerIdupcoming/customerClassesregistrationIdregistration/removeTime/instructor changes cannot be mitigated at all — manual edit in Fizikal, flagged loudly. Cancelled Move users need human outreach (we have their phone)
G4Placeholder customer pool must be created by hand (no customer create) and a customer probably can’t hold two seats in one occurrenceOutbound mirror capacityCreate ~max class capacity + headroom (start 40) placeholder customers in the Fizikal UI once: “Taikan Seat 01…40”. Pool size is bounded by the largest class, not by daily volumeOne-time manual setup per rails tenant. Pool exhaustion must alarm, not silently drop mirrors
G5Placeholders may need an entitlement to be registered (Q1)Option A entirelyFallback: create one ₪0 “Taikan Seat” price-list item by hand, then purchases/add (needs customerId + priceListItemId + salesmanId from employees/search) to provision and renewIf Fizikal decrements entriesLeft, renewal becomes a recurring job. Automatable, but it is upkeep
G6No webhooks; roster has no delta and no paginationFreshness and costTiered per-day polling, payload hashing, branch/hotupdates pre-check (probe first)Race window = poll cadence. Bounded, not eliminated
G7No seat hold, no idempotency keyDouble bookings on the last seat; duplicate seats on retryAggregator buffer B; mirror-first saga on the last k seats; re-read before any retry; persist registrationIdA truly simultaneous Move + Taikan booking on one remaining seat can still overbook. Buffer converts it into a chosen cost
G8Identity matching is fuzzyWrong person on a roster; duplicated humansPhone + national ID + email via the second-hop read; exact-single-match only; park ambiguity for manual resolutionManual queue work. Never auto-link — that rule is load-bearing
G9Cancellation/booking windows live in Fizikal’s class config (registrationEnd, lastCancelationTime), not in Taikan’s org settingsPolicy divergence: Move users cancel when Taikan wouldn’t allow itSet Fizikal template windows to match organizations.cancellationWindowHours at authoring time; drift detector compares themDivergence is invisible to the member. Detector reports it; a human fixes it
G10Reference-data ids differ (instructor, location, level, group)Mismapped classesOne-time mapping via `classes/locationslevels
G11AppTypeId undocumented (Q5)The cheap windowed capacity readclassDetails/guestView needs no AppTypeId — use per-occurrence reads; probe sweeps candidate values for the cheaper pathN+1 reads. Irrelevant at studio scale
G12Timezone and date-span serialization unconfirmedOff-by-hours occurrence matching — the worst class of bug here, because it looks like “sync is flaky”Probe captures raw values; parse defensively; assume Asia/Jerusalem and assert on round-tripDST boundaries. Add a fixture test for both DST sides
G13Rate limits undocumentedThrottling or a ban mid-serviceConservative queue limiter, breaker, exponential backoffUnknown ceiling until observed under real load
G14No sandbox tenantThe probe runs against a real box with a real Move audienceProbe on a far-future occurrence, capacity-1 class, placeholder customer, always rolls back; write phase is flag-gated and refuses to run without explicit idsA real FreeFit user could theoretically hit the probe class. Pick a class Move does not surface, or run off-hours
G15Fizikal is a competitor holding this dataStrategy, and the customer relationshipRails tenant stays data-minimal: placeholder seats mean we never export our member list to them — a privacy and strategy win, not just a workaroundThey see the studio’s grid and traffic shape. Accepted cost (ADR-0017)
G16Onboarding depends on third parties: gym must hold a ₪349 Fizikal subscription and get Move-approved on itTime-to-first-customerSequence per ADR-0017 D1; document as a customer-facing prerequisite runbookMove approval timeline is not ours to control

Explicitly rejected mitigation: driving Fizikal’s admin UI (Playwright/session transport) to create or edit classes. It would erase G2 and G3 entirely, and it is still the wrong call — it is the exact ToS exposure ADR-0017 D2 rejected for Arbox, and here the counterparty is a direct competitor who would be within their rights to terminate the customer’s subscription. If class authoring automation is ever worth the risk, it needs an explicit, written owner decision, not an engineering shortcut.

The probe (blocking, everything else waits on it)

scripts/fizikal-external-v1-probe.mjs — dependency-free ESM, read-only by default, write phase gated behind --allow-writes plus explicit ids and always rolling back.

Transport matters as much as the code. Fizikal allowlists our Railway static outbound IPs. railway run executes locally with remote env vars injected — it leaves from the workstation IP and will be rejected while looking like a credential problem. Use remote execution:

B64=$(base64 < scripts/fizikal-external-v1-probe.mjs | tr -d '\n') railway ssh --service @taikan/api --environment production \ "sh -c 'echo $B64 | base64 -d > /tmp/fz.mjs && FIZIKAL_API_KEY=… FIZIKAL_CUSTOMER_ID=1962 FIZIKAL_PROBE_PHONE=… node /tmp/fz.mjs'"

@taikan/api, not the scheduler — verified 2026-09-04: the scheduler has static outbound IPs disabled and Fizikal rejects its egress. Base64 rather than a stdin pipe because railway ssh does not reliably forward piped stdin to the remote process, and note that railway ssh may drop you into the container’s Node REPL rather than a shell, in which case paste JavaScript directly. Env vars belong on the remote side of the command, not yours.

Phases and what each answer changes:

Probe phaseQuestionIf it fails
auth variant matrixQ6 — X-API-key alone, or + X-Timestamp/X-Signature HMACAsk Fizikal support for the signing algorithm. Nothing else can run
classDetails/guestViewper-occurrence capacity without AppTypeIdfall back to the AppTypeId sweep; if both fail, capacity is unreadable and Option A is dead
registration/search sizingQ9 — payload size, latency, shape drift, whether waitlisted rows appearresize the poll window; expect drift (Arbox precedent: spec said string|null, reality returned ints)
customers/search + personaldetailscan a roster row be resolved to national ID/emailmatching degrades to phone-only; ambiguity queue grows
registration/add on an entitlement-free customerQ1fall back to purchases/add + a manual ₪0 item (G5)
totalParticipants before/afterQ2Option A is dead; fall back to Option B allocation and accept dead seats
fill to capacityQ3 — over-capacity error shape vs duplicate-booking error shaperetry logic cannot be written safely; treat every ambiguous failure as “re-read then decide”
entrances/searchQ7 evidence trailpair with a direct question to Movement Group
--discover-move (opt-in)is a Move-native surface reachable?inconclusive by design — 404 ≠ absent. The real answer is an email to Fizikal support

FIZIKAL_API_KEY, FIZIKAL_ORG_ID, FIZIKAL_COMPANY_ID, FIZIKAL_BRANCH_ID (+ optional FIZIKAL_API_SECRET, FIZIKAL_BARE_CUSTOMER_ID) go in Railway service variables. Setting production variables needs explicit owner approval (AGENTS.md); nothing in this plan does it autonomously.

Build phases

PhaseScopeGate
P0Probe. Script written, dry-run-verified, and already answering Q6/Q5 from off-net. Remaining: run it from RailwayRailway variables + owner approval
P1Rails-tenant setup runbook (weekly template grid, stub customer pool, id mapping) + read-only drift detector via customerView. Zero writes to FizikalP0 auth from a whitelisted IP
P2 (re-sequenced)Outbound first, because it is what the authorised surface allows and what protects members: outbox + stub-pool mirror + mirror-first saga + breaker + reconcilerQ1, Q2, Q3
P3Inbound: external_attendees + single-seat-ledger change to bookings + roster poller + EXTERNAL badge on the coach rosterclasses/registration/search being authorised
P4Attendance/check-in push (Q7-dependent), waitlist bridging (`waitinglist/addremove— noteremove` has no entry id, only customer+occurrence)
P5Direct Move slot (the endgame, FIT-269 Q6). This adapter is then deleted, not maintainedMovement Group

P2 and P3 swapped: with only four endpoints open, the outbound mirror is the half we can build, and with B = 0 it is also the half that protects members from being displaced by FreeFit traffic. P3 is gated on a third party granting one endpoint.

Open questions for the owner

All six of the original questions are answered (credentials issued, B = 0, no matching, no Move-native API, Linear epic to be created). What remains:

  1. Q7 remains open and is still the biggest business risk — how the studio is credited for a FreeFit visit, and whether Fizikal must record an entry for it. Owner is emailing Movement Group; draft in fizikal-vendor-emails-he.md.
  2. Does the test club have a Move/FreeFit connection at all? Q2 (does a mirrored booking change what Move shows) can only be fully proven on a Move-connected box. On the test club we can prove the weaker, still-decisive form: does registration/add increment totalParticipants.
  3. Which Railway service gets the key, and do the IPs Fizikal holds match that service’s current static outbound IPs? The ClientIp error makes this a one-call check.
  4. A signature-authenticated, non-IP-bound mode exists (see the confirmed-findings section). If they hand over the algorithm + secret, the Railway-only constraint disappears and testing gets much cheaper. Asked in fizikal-vendor-emails-he.md question ז.
  • ADR-0017 — vendor decision and open questions Q1–Q9
  • ADR-0015 — Arbox precedent; the only live-observed Move behaviour (live shared capacity, user_role: 'aggregatorMember', no check-in write)
  • docs/_archive/arbox-v3-validation.md — how a vendor probe is run and turned into a go/no-go
  • docs/runbooks/api-web-scheduler-split.md — where a poller may and may not run
  • Fizikal spec: https://api.fizikal.co.il/swagger/External_V1/swagger.json?apikey=fizikalguest21 (guest key opens the document only; Admin_V1, Admin_V2, App_V1, Pages_V1 all return empty)
  • fizikal-vendor-emails-he.md — the two outgoing emails (Fizikal: endpoint access + IP whitelist + technical questions; Movement Group: how a visit is credited), plain text, ready to send