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
- Bookings can be two-way. Classes cannot.
External_V1hasclasses/registration/add|removeand 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. - The seat is the only write primitive we have, and it is enough. One call —
registration/addon 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. - Move reads live occupancy. Fizikal’s Move-facing DTO (
_Custom.Move.ClassMove) carriesmaxParticipantsandtotalParticipants— the same pair asClassExternal. 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] - 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. - 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.
- 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.
- 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 fact | Why it matters |
|---|---|---|
| 1 | registration/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 → customerId → classes/upcoming/customerClasses → registrationId → remove. A second call per cancellation, but it means we can cancel Move bookings — ADR-0017 did not know this. |
| 2 | classDetails/guestView takes only Id + Dateclasses/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. |
| 3 | Occurrence 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. |
| 4 | purchases/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 them — CustomerMove 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. |
| 6 | X-API-key is the live auth channel, and ?apikey= is not. Verified from the workstation just now: ?apikey=fizikalguest21 → 401 "Missing Api Key"; X-API-key: fizikalguest21 → 401 "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):
| Endpoint | Gives 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/add | occupy a seat |
POST classes/registration/remove | release 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:
Q6answered.x-API-keyalone 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 asMissing Api Key.- 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. - An HMAC path also exists and is enforced when its headers are present. Sending
X-Timestampin the wrong format returns400 "Invalid X-Timestamp format. Expected yyyy-MM-ddTHH:mm:ss.fffZ in UTC"; sending it correctly returns401 "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. AppTypeId = 0is valid (their own example uses it). The Q5 unknown is closed for the endpoints we hold.classDateis date-only (2026-08-23) in their own examples, for both the read and both writes. So an occurrence key isclassId+ calendar date — no time component — which means the entity map must assume one occurrence perclassIdper day until proven otherwise. The probe checks this explicitly (schedule.duplicateClassIdsPerDay); if aclassIdever repeats within a day, that assumption and the mapping both change.registrationIdis a large int (974448028in their example) minted by Fizikal, required forregistration/remove, and readable back fromcustomerView— so a seat we lose track of is still recoverable, per customer.- There is no Move-native surface for us. Confirmed with the owner: what we see is what they have. The
_Custom.Movenamespace 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.
| Service | Static outbound IPs | Observed egress | Fizikal |
|---|---|---|---|
@taikan/api | enabled — 162.220.232.251, 152.55.177.192, 152.55.177.193 (sfo, “Shared”) | 152.55.177.192 | accepted |
@taikan/api-scheduler | enabled 2026-09-04 — 162.220.232.251, 152.55.176.240, 152.55.177.192 | still 54.219.27.66 — a redeploy is required before the static pool takes effect | pending |
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-08 → success: 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.
| Condition | HTTP | Envelope | How to detect |
|---|---|---|---|
| Already registered (duplicate add) | 200 | success: 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 DOB | 200 | success: false, message: "תאריך לידה לא תקין" | park the op; it will never succeed on retry |
| Unknown class or wrong date | 404 | exception: {message: "Class Not Found", entity: "Class"} | the occurrence key is wrong — re-resolve from customerView |
Stale / wrong registrationId, or already removed | 404 | exception: {message: "Entrance Not Found", entity: "Entrance"} | remove is not idempotent — treat this 404 as “already released” and close the op |
| Endpoint not in our grant | 401 | exception: {message: "Unauthorized External API (4)"} | configuration, not runtime — surface to staff |
| IP not whitelisted | 401 | exception: {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:
FromDatemay not precede Sunday of the current week —400 "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 days —
400 "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=1returned a different class count withmaxParticipants: 0on every row — a wrong value silently zeroes capacity instead of erroring. Pin it to0and assertmaxParticipants > 0on ingest.classDateaccepts both2026-09-08and2026-09-08T08:00:00; both booked successfully.- Response dates are naive local (
"2026-09-08T00:00:00"),startTime/durationare"HH:MM:SS". totalParticipantsin 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.textdoubles as a state machine:הרשמה(bookable),ביטול(I’m booked),סגור להרשמה(closed),תזכורת(reminder-only).
Scope re-confirmed live: classes/registration/search → 401 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
| Capability | Verdict | Mechanism |
|---|---|---|
| See FreeFit bookings on the Taikan roster | Yes | poll classes/registration/search (FromDate/ToDate), diff client-side |
| Stop Move overselling seats Taikan already sold | Yes, two ways | interim: cap maxParticipants at authoring time (manual, static). Target: mirror Taikan occupancy as placeholder seat occupation |
| Stop Taikan overselling seats Move already sold | Yes | inbound roster seats consume Taikan capacity like any other seat |
| Cancel a Move booking from Taikan | Yes | upcoming/customerClasses → registrationId → registration/remove |
| Push the Taikan schedule into Fizikal | No | no class create/update endpoint. Manual authoring + drift detection is the ceiling |
| Cancel/move a single occurrence via API | No, but mitigable | flood remaining seats with placeholders → occurrence is closed to Move. Time changes are unmitigable |
| Event-driven sync | No | no webhooks in the reachable surface. Poll only |
| Provision a FreeFit user as a Taikan member automatically | No (and out of scope) | no customer create in Fizikal; ADR-0017 D5 keeps members/payments off this pipe entirely |
| Write attendance back to Fizikal | Unclear | customer/entry is a facility check-in, not class-scoped; isArrived may be read-only to us (Q7) |
| Real-time (sub-minute) consistency | No | poll 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 newapps/api/src/fizikalmodule. Enqueued by a@Cronon@taikan/api-scheduler(CRONS_ENABLED=true, 1 replica — theautomation-scheduler.service.tsbackstop 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@Cronthere 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..ToDateresponse is the documented scale risk. - Diff, not upsert. Hash each day’s payload to skip unchanged days cheaply.
branch/hotupdatesis 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:
isArrivedon the roster row, withentrances/search(carriesclassId) as a cross-check.
Outbound loop (capacity mirror)
- Transactional outbox.
BookingsService.book()/cancel()insert an op row intoexternal_sync_opsinside 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.tsdefinesbooking.no_showandbooking.attendedonly (attendance, consumed by automations), and nothing inapps/api/src/bookingsemits 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 theautomation-engine.service.tspattern: 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/addwith a free stubcustomerId) andrelease_seat(registration/removewith the persistedregistrationId). No member identity, no membership provisioning, no PII leaves Taikan. - Circuit breaker per connection (
automation-circuit-breaker.service.ts): trip → connectionpaused, 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, andalready_registered/already_releasedare classified as successes (fizikal-outcome.ts) so a replay adopts the existing seat instead of creating a second one. Onlytransientis retryable; everything else parks.
Build state (2026-09-04)
Landed, typechecked, linted, 17 unit tests green:
| File | What |
|---|---|
libs/db/src/lib/schema/external-sync.ts | external_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.ts | integration_provider += 'fizikal'; external_entity_type, external_sync_op_type, external_sync_op_status |
libs/db/drizzle/0123_little_prism.sql | applied and verified on the local dev database; NOT applied to production — db: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.ts | hand-written wire types; generated ones would hide the spec-vs-reality gaps |
apps/api/src/fizikal/fizikal-outcome.ts | the error taxonomy as code — pure, unit-testable, no network |
apps/api/src/fizikal/fizikal.client.ts | x-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.ts | every fixture is a response the vendor actually returned; DST-boundary cases for the local-date rule |
libs/shared/.../feature-flags.ts | FeatureFlags.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.ts | the 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.ts | the 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.sql | external_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.
| Gate | Scope | Who uses it | Why it is not one of the others |
|---|---|---|---|
FIZIKAL_ENABLED (env) | whole subsystem, every club | on-call, during an incident | Instant, 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 only | on-call | The 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 club | product, during rollout | A 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 loop | support, per club | Lets 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:
| Property | Why it hides failure | Answer |
|---|---|---|
| Almost nothing throws | The client returns classified outcomes and the dispatcher parks poison ops, so a club whose every write parks looks like a club with no bookings | An 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 absence | A stalled occupancy poll raises nothing; capacity quietly stops accounting for aggregator seats and the first symptom is someone turned away at the door days later | A 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 failures | HTTP-level monitoring sees a perfectly healthy integration while nothing works | Outcome 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:
| Site | Behaviour with a NULL membership_id | Action for P3 |
|---|---|---|
bookings.service.ts capacity counts (:449, :487) | count by class_session_id only → external seats correctly consume capacity | none — 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 correct | none |
memberships.service.ts:761, notification-scheduler.service.ts:348 | innerJoin(memberships) silently drops external rows | none — 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: null | must handle — render name + EXTERNAL badge |
class-sessions.service.ts:977, :1146, :1455 (bulk cancel + notify) | fetches membership→user to notify; external rows have no user | must handle — cancel the seat, skip the email, enqueue a release_seat op |
booking-enforcement.util.ts:239 (waitlist promotion) | reads candidate.membershipId on promotion | safe 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 visitors | decide 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:1004 | counts waitlisted only | unaffected given the rule above |
Capacity policy — recommendation
| A: full-capacity mirror (recommended) | B: static allocation (ADR-0017 fallback) | |
|---|---|---|
Fizikal maxParticipants | the real capacity | deliberately capped (e.g. 5 of 20) |
| Outbound writes | yes, one per Taikan booking | none |
| Dead seats | none | yes, and permanent — no class-update endpoint means unsold Move seats can never be handed back |
| Gated on | Q1 (entitlement-free registration), Q2 (mirror visible to Move) | Q4 (Move honours the cap) |
| Failure mode | overbooking inside the race window | lost 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:
Aggregator buffer— decided 2026-09-04:BB = 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/customerClasses→registrationId→registration/remove.- Mirror-first on the tail stays, and with
B = 0it 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/removecompensates. 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 − aggregatorHeldSeatsThat closes the loop in both directions with the four endpoints Fizikal has already authorised:
| Aggregator overselling Taikan’s seats | Taikan overselling the aggregator’s seats | |
|---|---|---|
| Static allocation | prevented (hard cap) | prevented (cap Taikan too) — at the price of dead seats on both sides |
| Outbound mirror only | prevented | not prevented |
| Outbound mirror + count poll | prevented | prevented, 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 = 0that 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:
| Loop | Mechanism |
|---|---|
| Member books in Taikan → seat disappears from FreeFit | outbound mirror (built) |
| FreeFit books → seat disappears from Taikan | roster poll (registration/search), or the count poll where identity is not needed |
| Either side cancels → seat returns on the other | mirror release / roster diff |
| Coach roster shows FreeFit attendees by name | registration/search rows → external attendees, EXTERNAL badge |
| Attendance | isArrived inbound; entrances/search as a cross-check |
| Waitlist | `waitinglist/add |
| Capacity reads without a stub customer | schedule/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 class | partial 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:
- 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.
- Changing a class’s time. Same reason. Unmitigable.
- 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 = 0that resolves in the member’s favour with staff notified. - Forward-only repair.
FromDatecannot 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/entryis 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.
| Object | Shape | Notes |
|---|---|---|
integration_provider enum | + 'fizikal' | libs/db/src/lib/schema/enums.ts |
integration_connections row | reuse as-is | encrypted_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 uniques | One 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:
memberships.userIdisNOT NULLand FKs tousers(libs/db/src/lib/schema/memberships.ts:29), withunique(userId, organizationId). Every pooled membership therefore needs a fakeusersrow — in the table Clerk owns and syncs. That poisons the identity spine of the product to save one migration.- Reuse destroys history. Bookings are permanent records (
no_show/attended, insights,booking.attendedevents). Rebadging a pooled row per booking means last month’s attendance shows whoever occupies that slot today. - 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. - Fake memberships leak into every aggregate:
insights.service.tsmember 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. - 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 onorgId, default OFF, fail-closed —EventTrackingService.isFeatureEnabled, reference consumerWebhookEnforcementService. 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', categoryoperations. - i18n: new keys in all three locales, in both dictionary locations (
libs/shared/src/lib/i18n/dictionaries/{en,he,ru}.jsonandapps/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.
| # | Gap | Breaks | Mitigation | Residual risk |
|---|---|---|---|---|
| G1 | Move-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, silently | Answer Q7 with Movement Group and Fizikal before P3. If entries are required, push customer/entry on Taikan-side check-in of a matched Move attendee | customer/entry is not class-scoped, so it may not credit a specific class. Unresolvable from our side |
| G2 | No class create | Schedule authored twice, forever | Author 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-safe | Every grid change needs a human. Drift detector makes it visible, not automatic |
| G3 | No class update/delete — can’t cancel one occurrence, change time, or change capacity | Holiday closures, coach illness, capacity edits | Seat 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 → customerId → upcoming/customerClasses → registrationId → registration/remove | Time/instructor changes cannot be mitigated at all — manual edit in Fizikal, flagged loudly. Cancelled Move users need human outreach (we have their phone) |
| G4 | Placeholder customer pool must be created by hand (no customer create) and a customer probably can’t hold two seats in one occurrence | Outbound mirror capacity | Create ~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 volume | One-time manual setup per rails tenant. Pool exhaustion must alarm, not silently drop mirrors |
| G5 | Placeholders may need an entitlement to be registered (Q1) | Option A entirely | Fallback: create one ₪0 “Taikan Seat” price-list item by hand, then purchases/add (needs customerId + priceListItemId + salesmanId from employees/search) to provision and renew | If Fizikal decrements entriesLeft, renewal becomes a recurring job. Automatable, but it is upkeep |
| G6 | No webhooks; roster has no delta and no pagination | Freshness and cost | Tiered per-day polling, payload hashing, branch/hotupdates pre-check (probe first) | Race window = poll cadence. Bounded, not eliminated |
| G7 | No seat hold, no idempotency key | Double bookings on the last seat; duplicate seats on retry | Aggregator buffer B; mirror-first saga on the last k seats; re-read before any retry; persist registrationId | A truly simultaneous Move + Taikan booking on one remaining seat can still overbook. Buffer converts it into a chosen cost |
| G8 | Identity matching is fuzzy | Wrong person on a roster; duplicated humans | Phone + national ID + email via the second-hop read; exact-single-match only; park ambiguity for manual resolution | Manual queue work. Never auto-link — that rule is load-bearing |
| G9 | Cancellation/booking windows live in Fizikal’s class config (registrationEnd, lastCancelationTime), not in Taikan’s org settings | Policy divergence: Move users cancel when Taikan wouldn’t allow it | Set Fizikal template windows to match organizations.cancellationWindowHours at authoring time; drift detector compares them | Divergence is invisible to the member. Detector reports it; a human fixes it |
| G10 | Reference-data ids differ (instructor, location, level, group) | Mismapped classes | One-time mapping via `classes/locations | levels |
| G11 | AppTypeId undocumented (Q5) | The cheap windowed capacity read | classDetails/guestView needs no AppTypeId — use per-occurrence reads; probe sweeps candidate values for the cheaper path | N+1 reads. Irrelevant at studio scale |
| G12 | Timezone and date-span serialization unconfirmed | Off-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-trip | DST boundaries. Add a fixture test for both DST sides |
| G13 | Rate limits undocumented | Throttling or a ban mid-service | Conservative queue limiter, breaker, exponential backoff | Unknown ceiling until observed under real load |
| G14 | No sandbox tenant | The probe runs against a real box with a real Move audience | Probe on a far-future occurrence, capacity-1 class, placeholder customer, always rolls back; write phase is flag-gated and refuses to run without explicit ids | A real FreeFit user could theoretically hit the probe class. Pick a class Move does not surface, or run off-hours |
| G15 | Fizikal is a competitor holding this data | Strategy, and the customer relationship | Rails tenant stays data-minimal: placeholder seats mean we never export our member list to them — a privacy and strategy win, not just a workaround | They see the studio’s grid and traffic shape. Accepted cost (ADR-0017) |
| G16 | Onboarding depends on third parties: gym must hold a ₪349 Fizikal subscription and get Move-approved on it | Time-to-first-customer | Sequence per ADR-0017 D1; document as a customer-facing prerequisite runbook | Move 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 phase | Question | If it fails |
|---|---|---|
| auth variant matrix | Q6 — X-API-key alone, or + X-Timestamp/X-Signature HMAC | Ask Fizikal support for the signing algorithm. Nothing else can run |
classDetails/guestView | per-occurrence capacity without AppTypeId | fall back to the AppTypeId sweep; if both fail, capacity is unreadable and Option A is dead |
registration/search sizing | Q9 — payload size, latency, shape drift, whether waitlisted rows appear | resize the poll window; expect drift (Arbox precedent: spec said string|null, reality returned ints) |
customers/search + personaldetails | can a roster row be resolved to national ID/email | matching degrades to phone-only; ambiguity queue grows |
registration/add on an entitlement-free customer | Q1 | fall back to purchases/add + a manual ₪0 item (G5) |
totalParticipants before/after | Q2 | Option A is dead; fall back to Option B allocation and accept dead seats |
| fill to capacity | Q3 — over-capacity error shape vs duplicate-booking error shape | retry logic cannot be written safely; treat every ambiguous failure as “re-read then decide” |
entrances/search | Q7 evidence trail | pair 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
| Phase | Scope | Gate |
|---|---|---|
| P0 | Probe. Script written, dry-run-verified, and already answering Q6/Q5 from off-net. Remaining: run it from Railway | Railway variables + owner approval |
| P1 | Rails-tenant setup runbook (weekly template grid, stub customer pool, id mapping) + read-only drift detector via customerView. Zero writes to Fizikal | P0 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 + reconciler | Q1, Q2, Q3 |
| P3 | Inbound: external_attendees + single-seat-ledger change to bookings + roster poller + EXTERNAL badge on the coach roster | classes/registration/search being authorised |
| P4 | Attendance/check-in push (Q7-dependent), waitlist bridging (`waitinglist/add | remove— noteremove` has no entry id, only customer+occurrence) |
| P5 | Direct Move slot (the endgame, FIT-269 Q6). This adapter is then deleted, not maintained | Movement 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:
- 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. - 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/addincrementtotalParticipants. - Which Railway service gets the key, and do the IPs Fizikal holds match that service’s current static outbound IPs? The
ClientIperror makes this a one-call check. - 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.mdquestion ז.
Related
- 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-godocs/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_V1all 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