Skip to Content
Living documentation — last reviewed 2026-05-28
DecisionsADR-0015: Arbox parity mode ("Arbox Bridge")

ADR-0015: Arbox parity mode (“Arbox Bridge”)

Status: Proposed (design spike — no code yet; M1 validation complete, see 2026-07-29 addendum) Date: 2026-07-25 (amended 2026-07-26/27: Move/FreeFit middleman reframe; 2026-07-29: M1 results — see Addenda) Context owner: Saar Issue: FIT-258  (epic under FIT-65 , Linear project Move Integration, milestones M1–M5)

Like ADR-0011/0014 this is a design-spike ADR and deliberately exceeds the 200-line guideline: it is the canonical spec for the whole parity feature, not just one decision.

TL;DR — the decision

  1. Build parity mode as a one-directional-per-domain sync: Taikan → Arbox mirror for members, memberships and bookings; Arbox → Taikan poll-refresh for schedule and Arbox-side activity. Never two masters for the same field.
  2. Use the documented Arbox public v3 API (https://arboxserver.arboxapp.com/api/public/v3/*, header api-key) for all parity traffic. The v2 Business/Management APIs the importer uses stay import-only.
  3. Store the connection in integration_connections (new integration_provider value 'arbox'), not import_provider_configs.
  4. Add two new tables: arbox_entity_map (org-scoped id map, the single source of linkage and the echo-loop breaker) and arbox_sync_ops (outbox with per-aggregate FIFO, driving a new BullMQ arbox-mirror queue).
  5. Introduce the missing domain events (member.created/updated, booking.created/cancelled, subscription.created/cancelled/frozen/resumed) emitted post-commit at the existing service attachment points; an ArboxMirrorListener turns them into outbox ops. A nightly reconciliation sweep is the safety net for the (accepted) small gap between commit and listener.
  6. Billing has exactly one owner per member. Taikan-sold memberships are mirrored into Arbox as a client-created ₪0 non-recurring “Taikan” membership type. Payments are never mirrored, in either direction.
  7. Arbox stays schedule master during parity (the public v3 API cannot create classes — the staff-session API can; see the 2026-07-29 addendum). Taikan-created sessions are explicitly badged “Taikan-only”.
  8. Taikan never blocks on Arbox: mirror failures queue, retry with backoff, then park into a user-visible issues inbox. Circuit breaker pauses the org’s mirror on sustained failure.
  9. Rollout gated by PostHog flag arbox-parity-mode (org group, default OFF, unevaluable ⇒ OFF) plus platform-tier feature gate.
  10. A sandbox validation spike is a blocking prerequisite — the documented unknowns about v3 write semantics must be answered against a real box before Phase 1 code (FIT-259, now 14 questions). (2026-07-29: the read/propagation half is answered — see addendum; the write-semantics tail still needs a writable box.)
  11. (Amended 2026-07-26) The bridge serves two purposes: (a) the original migration parity, and (b) Move/FreeFit visibility via Arbox as a sanctioned middleman — see Addendum. Purpose (b) has its own go/no-go gate (FIT-269) that must pass before M2 foundation work starts.

Context

We already ship a one-time Arbox import (FIT-34: plans, members, membership enrichment; apps/api/src/import/). The gap is what happens after import: the client runs two systems during the transition, and everything done in Taikan is invisible in Arbox. That kills confidence — the owner cannot trust Taikan until the day they fully cut over, so they never start.

Parity mode inverts the risk: the client starts working in Taikan while Arbox stays correct. New members, membership assignments and class bookings made in Taikan appear in their Arbox minutes later; front-desk staff who still live in Arbox see a coherent picture; if the client aborts the migration, their Arbox is intact. It is a bridge, not an integration tier — success is the client turning it off.

What the Arbox public v3 API gives us (verified against the official OpenAPI spec, 46 paths):

  • Writes: POST/PATCH /v3/users; POST /v3/users/memberships (+ update, cancel, hold); POST /v3/schedule/bookSession (requires membership_user_id), POST /v3/schedule/cancelUserBooking, trial-booking create/delete; PATCH /v3/schedule (coach reassignment only); POST /v3/leads; POST /v3/users/recurringPayments (CHARGE/CANCELLED); push messages.
  • Reads: paged users / memberships / membershipTypes / locations / schedule (from_date+to_date, registration_details=1), searchUser by email/phone/id, 44 report endpoints with date windows.
  • Hard absences: no webhooks, no rate-limit contract, no idempotency keys, no class/session creation, no delta (“updated since”) queries, membership list returns type names not ids, all values typed string|null.

Codebase facts that shape the design (survey 2026-07-25): only external-id column today is member_profiles.arbox_user_id (non-unique, members only); no outbox and no domain events for most mutations (only membership.activated, booking.attended/no_show, subscription.renewed, lead events); integration_connections already models per-org external connections with status lifecycle + encrypted credentials; automations engine (ADR-0011) provides the listener/circuit-breaker/idempotency patterns to copy; importer persists plaintext credentials into import_jobs.config (pre-existing leak this work must fix).

Decision

D1. Sync topology: single-master per domain

DomainMaster during parityFlowNotes
Members (create/update)Taikanmirror → Arbox (POST/PATCH /v3/users, send_welcome_email:false)Arbox-side new members ingested by poller as Taikan members (flagged origin)
Memberships assign/cancel/holdTaikanmirror → Arbox (₪0 bridge type)never touch Arbox-priced rows except owner-initiated cancel/hold
Class bookings & cancellationsTaikanmirror → Arbox (bookSession / cancelUserBooking)inbound Arbox bookings ingested by poller
Schedule (classes)Arboxpoll → Taikan (windowed GET /v3/schedule)API cannot create Arbox classes; Taikan-created sessions stay local, badged
LeadsTaikan (optional, default OFF)mirror → Arbox POST /v3/leadsmost clients run leads only in Taikan
Paymentsn/anever syncedreconciliation report only
Attendance/check-inTaikan (best effort)none in v1no v3 check-in endpoint; revisit after spike

“Parity” therefore means: work in Taikan, Arbox follows; classes still authored in Arbox until cutover. This is honest about the API’s shape instead of promising bidirectional magic we cannot deliver.

D2. Connection & credentials

  • Add 'arbox' to integration_provider enum; one integration_connections row per org (status: pending|active|revoked|error, encrypted_credentials = AES-256-GCM via existing CredentialEncryptionService, external_account_id = Arbox box/location id from GET /v3/locations).
  • The import module keeps import_provider_configs; connect-flow can seed one from the other so the client pastes the key once. (The v3 api-key may or may not equal the v2 apikey — spike question; UI treats them as potentially distinct.)
  • Fix the pre-existing leak: ImportService must stop persisting resolved plaintext credentials into import_jobs.config (redact at write, not only at read).

D3. Identity map — arbox_entity_map

{ id, organization_id, entity_type ('member'|'membership_type'|'membership'|'schedule'|'booking'|'lead'|'location'|'staff'), taikan_id text, arbox_id text, origin ('import'|'mirror'|'poll'|'manual'), metadata jsonb, created_at, updated_at } with UNIQUE(organization_id, entity_type, arbox_id) and UNIQUE(organization_id, entity_type, taikan_id), indexed both ways.

  • Backfilled from member_profiles.arbox_user_id + import lineage on enable.
  • This table is the echo-loop breaker: the inbound poller skips any Arbox entity whose id is already mapped; the outbound mirror skips any Taikan entity whose origin is poll for the same change generation. Identity is never inferred from email/name once mapped.
  • Ambiguous matches (searchUser returns >1) are never auto-linked — they park into the issues inbox for manual resolution.

D4. Outbox — arbox_sync_ops + arbox-mirror queue

{ id, organization_id, op_type ('user.create'|'user.update'|'membership.assign'|'membership.update'|'membership.cancel'|'membership.hold'|'booking.create'|'booking.cancel'|'lead.create'), aggregate_key (e.g. member uuid — per-aggregate FIFO), dedupe_key unique-nullable, payload jsonb, status ('pending'|'in_flight'|'succeeded'|'failed'|'parked'|'superseded'), attempts, last_error, run_after, created_at, completed_at }.

  • Producers: ArboxMirrorListener (@OnEvent on domain events, automations-listener pattern — every handler try/caught so a mirror bug can never break the member-facing request).
  • Consumer: BullMQ arbox-mirror processor (ADR-0009 conventions, ObservableWorkerHost). Ordering: an op is only eligible when no earlier op with the same aggregate_key is unfinished — enforced by the dispatcher query, not queue order. A parked op blocks its aggregate’s later ops (create → assign → book is a hard chain) but never other aggregates.
  • Idempotency without server support: verify-then-apply. Before user.create, searchUser by normalized email then phone; after an ambiguous failure (timeout), re-verify before retry. Write returned Arbox ids into arbox_entity_map in the same DB transaction as marking the op succeeded.
  • Retry: exponential backoff (1m/5m/30m/2h/6h), then parked + issues-inbox entry. Org-level circuit breaker (copy automation-circuit-breaker.service.ts): sustained failure ratio pauses the org’s mirror, banner in UI, auto-probe half-open.
  • Rate limiting: per-org token bucket in the client (default ≤2 req/s, configurable via env) since Arbox documents no limits.
  • Delivery guarantee: at-least-once from outbox onward; the commit→listener gap is closed by reconciliation (D6). We explicitly did not choose a transactional outbox written inside every service transaction — see Alternatives.

D5. New domain events (needed regardless of Arbox)

member.created, member.updated (users/memberships services + lead-convert), booking.created, booking.cancelled (incl. admin cancel + waitlist promotion), subscription.created, subscription.cancelled, subscription.frozen/resumed — emitted post-commit at the attachment points already identified in the survey (bookings.service.ts book/cancel/adminCancel/promoteFromWaitlist; memberships.service.ts accept/join/convert paths; subscriptions.service.ts lifecycle methods). Automations and analytics want these anyway; the mirror is just another subscriber.

D6. Inbound poller + reconciliation

  • Fast loop (5–10 min, flag-tunable): GET /v3/schedule?from_date=today&to_date=+14d&registration_details=1 → upsert Arbox-originated classes/bookings into Taikan (origin poll in the map). This is what makes Arbox-side front-desk activity visible in Taikan.
  • Slow loop (nightly): paged GET /v3/users + GET /v3/users/memberships full scan → (a) ingest new Arbox-side members, (b) drift report: mapped entities whose state diverges (member active/inactive mismatch, membership dates, double-active-paid-membership watchlist), (c) re-drive any Taikan mutation that missed its mirror (compares Taikan state vs map vs Arbox state).
  • No updated_since API exists, so full scans are the only option; the token bucket and nightly cadence keep load polite. Reports (bookingsReport etc.) may offer cheaper deltas — spike question.

D7. Billing ownership (the one rule that must never break)

  • Each member has exactly one billing owner: arbox (legacy recurring stays in Arbox) or taikan (Morning). Stored per member (map metadata), surfaced in UI.
  • Mirrored memberships always use the client-created ₪0 non-recurring “Taikan” membership type in Arbox (preflight enforces it exists before enabling membership/booking mirror). Arbox therefore never charges for anything Taikan sold.
  • Taikan never calls POST /v3/users/recurringPayments automatically. A guided, explicit, per-member owner action (“move billing to Taikan”) may cancel the Arbox recurring payment — logged, confirmed, reversible-instructions shown.
  • The nightly drift report flags any member with active paid memberships in both systems.

D8. Failure semantics & product guardrails

  • Taikan request paths never await Arbox. Mirror is eventually consistent; UI states the expected lag (“appears in Arbox within minutes”, “Arbox changes appear within ~10 minutes”).
  • Capacity race (class fills in Arbox after Taikan showed space): Taikan booking stands; failed mirror parks with a staff-facing resolution card. We do not auto-cancel member bookings.
  • Kill switches: PostHog flag OFF pauses all mirroring instantly (queued ops held, not dropped); disconnect stops writes and purges queued payloads (PII hygiene) while keeping the map for audit.
  • Non-prod safety: the Arbox client refuses to run unless NODE_ENV=production or an explicit ARBOX_SANDBOX_ALLOW env allowlist matches — e2e/dev must never write to a real client box (same incident class as the e2e DB wipe).

D9. Gating & rollout

  • PostHog flag arbox-parity-mode, organization group, default OFF, fail-closed (unevaluable ⇒ mirror paused, Taikan unaffected) — per the feature-flag policy; reference implementation WebhookEnforcementService.
  • Platform tier: enforce the already-defined-but-unenforced arbox_migration feature for import, add arbox_parity for Bridge (ADR-0008).
  • First org: Erez’s box only after the sandbox spike passes and dry-run mode (ops generated + logged, not sent) has run clean for several days.

Consequences

Positive

  • Removes the single biggest onboarding objection (“I can’t risk my Arbox data”); the client can adopt Taikan incrementally with a visible safety net.
  • The domain events, entity map and outbox are durable platform assets (MOVE/FreeFit integration FIT-65, future webhooks, analytics) — not Arbox-specific throwaways.
  • Single-master-per-domain + ₪0 bridge memberships keep the failure modes boring: worst realistic case is stale, not corrupted or double-charged.

Negative / costs

  • We build on a competitor’s unversioned (0.0.1), webhook-less API that they can throttle, gate or revoke at any moment; parity must always degrade gracefully to import-only, and we must never market it as guaranteed. Legal posture: the box owner supplies their own key and consents to Taikan acting as their agent on their own data — review Arbox ToS before first paying client.
  • Polling full member lists nightly is inelegant and caps practical org size (fine ≤ a few thousand members; revisit if a big box signs).
  • Two new tables, a queue, a poller and ~8 new domain events is real surface area (~estimated 4–6 weeks across phases). (Amended: originally justified as a feature whose success ends in its own disablement; the Move reframe makes it standing infrastructure for Move-dependent gyms — see Addendum.)
  • The commit→listener gap means a crashed process can delay (not lose — reconciliation re-drives) a mirror write; we accept up to 24h worst-case for the rare crash window instead of invasive per-transaction outbox writes.
  • Attendance/check-in parity is out of scope v1 (no endpoint); Arbox entrance reports remain the client’s check-in record until cutover.

Alternatives considered

  1. Periodic re-import only (one-way refresh). Rejected: does not satisfy the actual need — users acting in Taikan must appear in Arbox, or staff cannot trust either system.
  2. True bidirectional field-level sync with conflict resolution. Rejected: no webhooks, no deltas, no idempotency on the Arbox side makes correct bidirectional merge undeliverable; single-master per domain is what the API can honestly support.
  3. v2 User-API write path (POST /scheduleUser/insert hack from discovery). Rejected as primary: undocumented, requires member-level or staff email+password credentials; the public v3 API is documented, key-authed and covers users/memberships/bookings. v2 stays a read-only fallback for gaps (e.g. reports).
  4. Transactional outbox written inside every service transaction. Rejected for v1: touches every mutation path invasively; listener-produced outbox + nightly reconciliation gives at-least-once with far less blast radius. Revisit if reconciliation-caught gaps show up in practice.
  5. Building mirror logic into the automations engine. Rejected: automations are user-configurable org logic; parity is system infrastructure with different failure semantics (FIFO per aggregate, circuit breaker to a partner API). We copy its patterns, not its runtime.
  6. Mirroring payments/charges. Rejected outright: double-billing risk dwarfs the reporting benefit; reconciliation report instead.

Addendum (2026-07-26): Move/FreeFit middleman reframe

During the Move Integration consolidation the bridge picked up a second, strategically distinct purpose.

Context. Move/FreeFit (Movement Group) gates its booking integration to approved gym-management systems and will not onboard Taikan directly (FIT-65). For gyms already on Arbox, the bridge lets Arbox act as a sanctioned middleman: the gym runs Taikan day-to-day, Arbox keeps the class grid and the Move connection, and the bridge syncs between them. Move members keep booking through Arbox’s Move link; Taikan-side bookings flow into Arbox and (assumption) decrement Move-visible capacity.

Load-bearing assumption — validated before M2, not assumed (FIT-269, Urgent, milestone M1):

  1. Bookings written into Arbox via POST /v3/schedule/bookSession propagate through Arbox’s own Move link (capacity decrements on the Move surface).
  2. Move-originated bookings are visible to us in GET /v3/schedule?registration_details=1 — and with what identity (real member record vs anonymous placeholder). If either fails, the Arbox-as-middleman thesis is NO-GO and M2–M5 are re-scoped before any schema lands. Evidence must be observed (Move-surface screenshots + raw payloads), not vendor assurances.

What the reframe changes in this ADR:

  • Graduation is now conditional. For migration-only gyms, the original framing holds: success = the client turns the bridge off. For Move-dependent gyms, the bridge is standing infrastructure until Taikan obtains a direct Move slot (timeline/cost tracked in FIT-269 Q6) — classes stay authored in Arbox indefinitely for those orgs, and the “Finish your move” UX must distinguish the two modes (a Move-dependent org “graduates” from migration while keeping the schedule/booking sync alive).
  • The spike grew (FIT-259 Q13/Q14 + fixtures exit bar): Q13 — are Move/FreeFit-originated registrations present in registration_details and counted in registration_count; Q14 — pagination semantics of GET /v3/schedule (the poller needs resumable cursors). Recorded request/response payloads are committed to the repo as test fixtures so the client (FIT-260) and poller (FIT-264) tests run against real shapes.
  • Design fork, resolved by Q11+Q13: mirror who is booked (full roster incl. Move members ingested as Taikan records) vs only a number (count-only display with a “managed in Arbox” badge). M4 (FIT-263/FIT-264) branches on the answer; do not pre-build either branch before the spike lands.
  • Milestones: the FIT-258 tree is organized as M1 Contract & Spike (FIT-259, FIT-269) → M2 Foundation (FIT-260/261/262/265) → M3 Read Sync (FIT-264) → M4 Bookings (FIT-263) → M5 Hardening & Rollout (FIT-266/267/270, incl. the Move end-to-end pilot FIT-270).
  • Additional legal surface: beyond Arbox ToS, whatever agreement exists between Arbox and Movement Group may constrain a third party driving the box’s Arbox account via API — reviewed as part of FIT-269 Q5 before the first paying pilot.

Endgame remains a direct Move slot. The bridge buys time and proves demand; FIT-269 Q6 re-opens the direct-integration conversation with Movement Group so we know how long the middleman has to live.

Addendum (2026-07-29): M1 results — thesis validated, three constraints corrected

M1 ran against the live Move-connected pilot box (read-only except one owner-approved book/cancel pair). Full evidence: docs/_archive/arbox-v3-validation.md; go/no-go recorded on FIT-269.

The load-bearing assumption holds — GO, both directions.

  1. An API-written bookSession decremented the Move app’s visible remaining capacity (badge went 4 → 3 spots, restored on cancel). Observed on the Move surface, not asserted by a vendor.
  2. Aggregator-originated bookings are visible to us with real identity: registration_Details (note the capital D — the spec’s prose says otherwise) carries user_id, name and phone per seat, discriminated by user_role: 'aggregatorMember', and is counted in registration_count (verified across 200/200 sessions). A 19-month sweep of attendanceExternalMembersReport (6,364 rows) found every aggregator visit funnelled through a single FREEFIT membership type; two known aggregator-app users were located by phone. There is no separate Move pipe and no anonymised placeholder.

⇒ The who-vs-count fork resolves to WHO. FIT-263/FIT-264 build the per-member roster branch. The count-only fallback stays documented but unbuilt.

Three things this ADR asserted that turned out to be wrong or narrower than stated:

  1. “The API cannot create classes” is true of the public v3 API only. The Arbox staff dashboard drives arboxserver.arboxapp.com/api/manage/v2/* with a login session and exposes schedule/createEvent, updateEvent, updateSchedulesBetweenDates, createOrUpdateEventType, duplicateEventType — plus scheduleUser/insert for bookings. So “Arbox stays schedule master” (D1, D8, the Taikan-only-class badge) is a consequence of the transport we chose, not of Arbox. See the transport note below.
  2. Attendance is readable inbound. D1 scoped attendance out for lack of an endpoint, but registration_Details.checked_in and bookingsReport.check_in both expose it. Arbox → Taikan attendance ingest is feasible in v1 and should be considered for FIT-264 scope; only the write direction is genuinely absent.
  3. Identity is messier than D3 assumed. Arbox does not dedup aggregator records against native members — one human legitimately holds two user_ids with the same phone (native, with email; aggregator, email null). searchUser by phone returning >1 is therefore normal, not an anomaly, and the poller must expect aggregator shadows of existing members. The never-auto-link rule stands and now has a concrete failure mode behind it.

Also corrected/confirmed at the API level: v2 and v3 share one api-key (the connect flow can reuse the import key, D2); a bad key returns a real HTTP 401, so the v2 “200-with-error-body” quirk does not reproduce on v3 (but /v3/customFields answers without the envelope, so the client must tolerate both); membershipTypes returns real ids, so plan mapping is id-based; GET /v3/users/memberships?user_id= reliably returns membership_user_id, which is the D4 fallback for Q10; bookSession’s success body is mostly nulls and its booking_id echoes the schedule_id, so bookings must be keyed on (schedule_id, user_id) and confirmed by the next poll rather than trusted from the write response; aggregator records are not writable via the api-key (400 user_id is not valid), which makes the D3 echo-guard structural for Move seats rather than something we enforce.

New constraint the ADR did not anticipate: tier economics. Arbox gates by plan — the api-key is Standard-only (₪406/mo) while the Move/FreeFit link needs only Basic (₪157/mo), and Basic caps the box at 20 classes/week. A gym that wants its whole grid on Move needs Standard regardless of us; a gym exposing only a Move-facing subset (the pilot box’s aggregator traffic touched ~14 sessions/week) could live on Basic, but Basic has no api-key. That makes the login-session path an economic question rather than the merely-undocumented alternative rejected in Alternatives §3.

Consequent design amendment — the transport seam. Everything in D3–D6 (entity map, outbox, per-aggregate FIFO, circuit breaker, poller) is transport-agnostic and unchanged. Introduce one interface with two implementations: ArboxApiKeyTransport (public v3, Standard boxes — build first, it is documented and validated) and ArboxSessionTransport (manage/v2 via the box’s login, Basic boxes). Per-org choice, config-driven. The session transport additionally exposes booking-notification control (sendInvitation, alongside send_push/send_sms/send_email in the adjacent messaging payload), which may resolve spike Q5 — the notification side-effect launch blocker the public API gives us no lever over.

Unresolved, and deliberately so: whether the session transport works on a non-Standard box (needs one hour on a paid playground box); the ToS posture of reproducing the staff client specifically to avoid a paid gate — heavier than using an issued key, and a business/legal call, not an engineering one (FIT-269 Q5); and the remaining write-semantics questions (Q3 dedup, Q4 delete, Q5 notifications, Q7 late-cancellation), all of which need a writable box. Status stays Proposed until Saar signs off per FIT-267.

  • ADR-0004 (org isolation), ADR-0008 (tier gating), ADR-0009 (BullMQ jobs), ADR-0011 (automations engine — listener/circuit-breaker/idempotency patterns), FIT-34 (import), FIT-157 (automations import), FIT-65 (MOVE integration — future consumer of the same event/outbox infra).
  • Feature docs to be created at docs/features/arbox-bridge/ during implementation; runbook docs/runbooks/arbox-bridge.md (key rotation, circuit-breaker ops, disconnect).
  • Arbox v3 OpenAPI: https://arboxserver.arboxapp.com/docs/api.json; discovery notes docs/_archive/arbox-discovery-findings.md.