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
- 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.
- Use the documented Arbox public v3 API (
https://arboxserver.arboxapp.com/api/public/v3/*, headerapi-key) for all parity traffic. The v2 Business/Management APIs the importer uses stay import-only. - Store the connection in
integration_connections(newintegration_providervalue'arbox'), notimport_provider_configs. - Add two new tables:
arbox_entity_map(org-scoped id map, the single source of linkage and the echo-loop breaker) andarbox_sync_ops(outbox with per-aggregate FIFO, driving a new BullMQarbox-mirrorqueue). - 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; anArboxMirrorListenerturns them into outbox ops. A nightly reconciliation sweep is the safety net for the (accepted) small gap between commit and listener. - 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.
- 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”.
- 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.
- Rollout gated by PostHog flag
arbox-parity-mode(org group, default OFF, unevaluable ⇒ OFF) plus platform-tier feature gate. - 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.)
- (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(requiresmembership_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),searchUserby 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
| Domain | Master during parity | Flow | Notes |
|---|---|---|---|
| Members (create/update) | Taikan | mirror → Arbox (POST/PATCH /v3/users, send_welcome_email:false) | Arbox-side new members ingested by poller as Taikan members (flagged origin) |
| Memberships assign/cancel/hold | Taikan | mirror → Arbox (₪0 bridge type) | never touch Arbox-priced rows except owner-initiated cancel/hold |
| Class bookings & cancellations | Taikan | mirror → Arbox (bookSession / cancelUserBooking) | inbound Arbox bookings ingested by poller |
| Schedule (classes) | Arbox | poll → Taikan (windowed GET /v3/schedule) | API cannot create Arbox classes; Taikan-created sessions stay local, badged |
| Leads | Taikan (optional, default OFF) | mirror → Arbox POST /v3/leads | most clients run leads only in Taikan |
| Payments | n/a | never synced | reconciliation report only |
| Attendance/check-in | Taikan (best effort) | none in v1 | no 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'tointegration_providerenum; oneintegration_connectionsrow per org (status: pending|active|revoked|error,encrypted_credentials= AES-256-GCM via existingCredentialEncryptionService,external_account_id= Arbox box/location id fromGET /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 v3api-keymay or may not equal the v2apikey— spike question; UI treats them as potentially distinct.) - Fix the pre-existing leak:
ImportServicemust stop persisting resolved plaintext credentials intoimport_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
pollfor 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(@OnEventon domain events, automations-listener pattern — every handler try/caught so a mirror bug can never break the member-facing request). - Consumer: BullMQ
arbox-mirrorprocessor (ADR-0009 conventions,ObservableWorkerHost). Ordering: an op is only eligible when no earlier op with the sameaggregate_keyis 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,searchUserby normalized email then phone; after an ambiguous failure (timeout), re-verify before retry. Write returned Arbox ids intoarbox_entity_mapin 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 (copyautomation-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®istration_details=1→ upsert Arbox-originated classes/bookings into Taikan (originpollin the map). This is what makes Arbox-side front-desk activity visible in Taikan. - Slow loop (nightly): paged
GET /v3/users+GET /v3/users/membershipsfull 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_sinceAPI exists, so full scans are the only option; the token bucket and nightly cadence keep load polite. Reports (bookingsReportetc.) 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) ortaikan(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/recurringPaymentsautomatically. 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=productionor an explicitARBOX_SANDBOX_ALLOWenv 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,organizationgroup, default OFF, fail-closed (unevaluable ⇒ mirror paused, Taikan unaffected) — per the feature-flag policy; reference implementationWebhookEnforcementService. - Platform tier: enforce the already-defined-but-unenforced
arbox_migrationfeature for import, addarbox_parityfor 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
- 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.
- 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.
- v2 User-API write path (
POST /scheduleUser/inserthack 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). - 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.
- 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.
- 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):
- Bookings written into Arbox via
POST /v3/schedule/bookSessionpropagate through Arbox’s own Move link (capacity decrements on the Move surface). - 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_detailsand counted inregistration_count; Q14 — pagination semantics ofGET /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.
- An API-written
bookSessiondecremented 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. - Aggregator-originated bookings are visible to us with real identity:
registration_Details(note the capital D — the spec’s prose says otherwise) carriesuser_id, name and phone per seat, discriminated byuser_role: 'aggregatorMember', and is counted inregistration_count(verified across 200/200 sessions). A 19-month sweep ofattendanceExternalMembersReport(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:
- “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 exposesschedule/createEvent,updateEvent,updateSchedulesBetweenDates,createOrUpdateEventType,duplicateEventType— plusscheduleUser/insertfor 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. - Attendance is readable inbound. D1 scoped attendance out for lack of an endpoint, but
registration_Details.checked_inandbookingsReport.check_inboth 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. - 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).searchUserby 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.
Related
- 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; runbookdocs/runbooks/arbox-bridge.md(key rotation, circuit-breaker ops, disconnect). - Arbox v3 OpenAPI:
https://arboxserver.arboxapp.com/docs/api.json; discovery notesdocs/_archive/arbox-discovery-findings.md.