Arbox Bridge: gradual migration from Arbox
Epic TKN-258; decisions in ADR-0015 (2026-09-26 amendment). The bridge lets a gym run Taikan day to day while Arbox, and the Move/FreeFit link that hangs off it, keep an accurate picture. It is available to every organization’s owner; nothing writes to Arbox until the owner connects a key, and a new connection starts in dry run. Live writes are pending live write validation. TKN-332’s official-API-only rule applies: documented public v3 routes with the gym’s own API key, nothing else.
What we tell gym owners (what to do in Arbox vs Taikan, what syncs, known limits) lives in client-guide.md; in-app help and support scripts derive from it.
Directions at a glance
| Domain | Taikan → Arbox | Arbox → Taikan |
|---|---|---|
| Classes | Coach change only (public v3 cannot create or edit classes); owner worklist for the rest | Import every five minutes (today to +14 days) |
| Bookings | Mirror within seconds of each booking, cancellation or promotion | Roster import; mapped seats become Taikan bookings |
| Members | Create/update Arbox user (search first, no welcome email) | One-time import plus the ID map |
| Memberships | Assign/cancel the owner’s ₪0 non-recurring bridge membership | Read only (entitlement checks) |
| Payments | Never | Never |
Sync loop
A one-minute scheduled tick enqueues one deduplicated job per active organization on the dedicated arbox-sync BullMQ queue; it no longer runs vendor calls on the shared scheduled-jobs worker. Every booking, cancellation, admin cancellation and waitlist promotion also calls ArboxSyncNudgeService.nudge(orgId) after commit. The nudge checks a cached, indexed connection-row lookup (no PostHog call on the booking path), enqueues the org job with a 2-second delay for batching, and never throws into the request.
Each run holds the org advisory lock and executes, in order: schedule import (five-minute cadence or forced), member reconcile (when the members toggle is on), booking reconcile, then dispatch (live mode only). Dispatch routes each op by type: booking.* to the booking executor, user.*/membership.* to ArboxMemberMirrorService, schedule.coach to ArboxClassesService. Retryable vendor failures back off 1m/5m/30m/2h/6h, then park. Ops for sessions that already started (create) or ended (either) are superseded as session_past. An in_flight op older than ten minutes is treated as crashed and re-verified.
The schedule import paginates to an empty terminal page, validates the whole window, then commits one serializable transaction. A malformed or repeated page cannot remove classes or bookings. A session whose local start falls in a DST gap is accepted at the shifted instant instead of failing the window. Local soft-deleted classes are not resurrected.
Official API boundary
Only these exact methods/paths are allowed beneath https://arboxserver.arboxapp.com/api/public (ARBOX_ALLOWED_ROUTES), checked against the official OpenAPI document on 2026-09-26:
| Method | Paths |
|---|---|
| GET | /v3/locations, /v3/membershipTypes, /v3/membershipCancelReasons, /v3/schedule, /v3/users, /v3/users/searchUser, /v3/users/memberships, /v3/users/allStaffMembers |
| POST | /v3/schedule/bookSession, /v3/schedule/cancelUserBooking, /v3/users, /v3/users/memberships, /v3/users/memberships/cancel |
| PATCH | /v3/users, /v3/schedule (coach only) |
The transport sends the key in the api-key header, rejects redirects and rejects every other route before sending credentials. Writes are never retried by the transport and are refused outside production unless the box ID is listed in ARBOX_SANDBOX_ALLOW. Messages, recurring payments, trials, leads, finance and deletes are deliberately absent. The per-org rate cap (ARBOX_RATE_LIMIT_RPS, default 2) is shared across replicas through Redis and falls back to an in-process bucket when Redis is not configured.
Bookings: local-first
A Taikan booking commits exactly as it would without the bridge: capacity (minus unlinked external seats from the last roster), credit, confirmation and notifications. The mirror then verifies the Arbox roster before writing, writes, and verifies again. An uncertain response is never re-posted blindly: the next attempt reads the roster first, so a lost-but-applied booking completes without a second bookSession. Cancellations always send late_cancellation: false; a cancellation whose Arbox class no longer exists succeeds, because absence proves there is no seat.
Arbox and Taikan cannot reserve a seat atomically. When Arbox rejects a mirrored booking (capacity_conflict, vendor_rejected_booking) or an external edit contradicts a confirmed mirror (booking_drift), the Taikan booking stands, the op parks, and every active org owner receives one email and push (claimed via alerted_at, so replicas cannot double-send). Members are never cancelled or shown bridge terminology.
Bookings of members with no Arbox identity are skipped entirely when the members toggle is off (counted as memberSync.unlinked). With the toggle on, the booking waits (no attempt consumed) while the member mirror creates the identity and bridge membership; a blocked member parks as member_mapping_required.
Members and the bridge membership
ArboxMemberMirrorService reconciles up to 200 members per run: rows changed since memberWatermark, plus active members whose mirror is incomplete. It writes id-only ops keyed on the user id; newer user.update ops supersede older pending ones.
user.createsearches by lowercased email, then by phone in Arbox’s national format (toArboxPhone). One native hit links; several park asidentity_conflict; aggregator-only hits count as none. With no hit it creates the user without a welcome email, then re-searches to obtain the id; a failed create is re-searched before any retry, so a person is never created twice.user.updatepushes name/email/phone differences from the last pushed snapshot.membership.assignkeeps an existing valid free entitlement; otherwise it creates the owner-selected ₪0 non-recurring type dated today in the org timezone and re-lists to confirm exactly one active bridge membership (membership_not_verifiedotherwise).membership.cancelcancels only mirror-created bridge memberships, with the owner-selected cancel reason (membership_cancel_unsupportedwhen unset). Arbox-priced memberships are never touched.
Owners can still link identities by hand, see every link, and unlink one (audited; Arbox untouched). Aggregator (Move/FreeFit) identities are readable but never written.
Class ownership and the gradual cutover
Arbox’s public v3 cannot create a class and can change exactly one thing about one — its coach (PATCH /v3/schedule). Ownership is therefore decided per series, not per session. A series key is ${normalizedTitle}|${isoWeekday 1-7}|${HH:mm} computed in the org timezone (arboxSeriesKey), so it survives sessions being added, moved or deleted, and survives DST.
- Arbox-owned (default). Sessions import.
title,startsAt,endsAt,date,capacity, cancelling, unpublishing and deleting are refused in Taikan with 409arbox_managed_field. The coach may be changed only to a coach mapped to an Arbox staff member (entitystaff); anything else is 400arbox_staff_mapping_required, and editing the free-text guest coach is 400arbox_external_coach_locked. An allowed change queues aschedule.coachop keyed on the session id, so a second change supersedes the first rather than racing it. The push re-reads the day and compares the staff id: an acknowledged-but-unapplied change is retried, never recorded as done. - Taikan-owned (
taikan). Taikan authors. The poll never writes a field and never cancels the session when Arbox stops reporting it. It still links an unlinked Arbox session of that series, but only on an unambiguous exactstartsAtmatch. Unmatched Arbox sessions are listed as the owner task “In Arbox but not in Taikan” and are never imported. Bookings on linked sessions keep mirroring both ways. taikan_only. Astaikan, and the series is additionally excluded from the “Create in Arbox” worklist.
A Taikan-created future session with no Arbox link appears in “Create in Arbox” (Arbox has no endpoint that could mirror it). When the poll sees an unlinked Arbox session with identical startsAt+endsAt and equal normalized title, it records a candidate instead of importing, so one class typed into both systems never becomes two rows. Confirming links the pair (entity_map schedule, origin manual) and the session becomes Arbox-managed; rejecting stamps rejected_at, and the Arbox session imports as its own class on the next poll and is never re-proposed. An ambiguous match records one candidate per equally-good Taikan session and imports nothing until a human decides.
Divergence: when the two copies stop agreeing
A Taikan-owned class stays editable, which means it can be moved, cancelled or deleted while the Arbox occurrence carries on standing at the old time taking bookings from the front desk and from Move. Every import pass therefore stamps the link (arbox_entity_map entity schedule) with {arboxStartsAt, arboxEndsAt, seriesKey} — what Arbox says right now, including for a class that is soft-deleted locally. seriesKey is computed from the ARBOX occurrence, so moving a class in Taikan never hands its series back to Arbox; the stored key is what the import, the editor guard and the worklist all read.
arboxLinkDivergence (arbox-sync.policy.ts) reads that snapshot: a linked class is diverged when it is soft-deleted, when its status is not published, or when its start or end no longer matches Arbox. An Arbox-owned series cannot drift by time (the import overwrites it) but can be soft-deleted, which is the same stale-room problem.
A diverged class stops mirroring: booking reconcile flips the desired state to “no seat”, so no new booking.create is queued and every seat the bridge itself placed gets a booking.cancel that frees the stale Arbox place. The cancel reads the ARBOX occurrence’s own day, not the Taikan one, or it would find no schedule and declare a cancellation done that never happened. execute re-checks divergence immediately before writing and retires a create queued before the move with the code class_diverged. When the owner makes Arbox match again, the next poll rewrites the snapshot and the seats are re-booked.
While it lasts, the class appears in the owner’s updateInArbox worklist with both times and whether the Arbox class needs moving or cancelling — only while Arbox still reports the occurrence, because telling someone to cancel a class that is already gone is worse than silence. If the two really are different classes now, POST classes/links/:sessionId/unlink cuts them apart: only a diverged link (400 arbox_link_not_diverged), and never while the bridge still holds mirrored seats in Arbox (409 arbox_link_has_seats) since the link is the only thing that knows how to release them. The unlink drops the observation row with the link, or its org+schedule uniqueness would block the occurrence from ever being imported again; afterwards it shows up in arboxOnly.
Owner endpoints (owner-only, serialized on the owner advisory lock): GET classes/tasks, POST classes/candidates/:id/confirm|reject, POST classes/links/:sessionId/unlink, PUT classes/series, GET|POST staff, DELETE staff/:membershipId. Every one is audited with ids only. The schedule-editor guard is a no-op for orgs with no integration_connections row — one cached, indexed lookup. The same decision is published to staff as arboxManaged on class-session read responses (one batched lookup per response, staff only — the field is absent, not false, for members), so the editor can grey a locked class out instead of waiting for the 409.
Owner controls and storage
Settings → Integrations (owner only) exposes: connect/disconnect, class type, independent toggles for schedule import, members, memberships (requires members, a verified ₪0 non-recurring bridge type and a cancel reason) and bookings (requires schedule import), dry run/live (live requires an approved test reference), manual sync, backfill, preflight identity match rate, server-computed bridgeHealth (ok/lagging/attention/paused), queue depth and oldest pending op, member sync counts, parked/failed issues with audited Retry and Dismiss, member links with unlink, the class worklists, series ownership and staff mapping. API error codes are shown through the dictionary.
integration_connections: encrypted key, config (toggles, class type, bridge membership type, cancel reason, test reference, member watermark) and last-poll/error codes.arbox_entity_map: org-scoped one-to-one links for members, bridge memberships, schedules, bookings and staff. An upsert never re-points an existing link.arbox_sync_ops: durable intents withcorrelation_id,started_at, retries, stable error codes,alerted_at, andresolved_by/resolutionfor owner actions.arbox_schedule_state: last validated roster per session (Arbox user id, name, aggregator and attendance markers), capacity and observation time. No phone, medical, financial or raw vendor data.arbox_series_ownership,arbox_class_candidates: per-series authoring and pending duplicate matches.
Disconnect clears the key, supersedes queued work and purges its payloads, empties stored rosters and deletes candidates. Imported records and links remain; confirmed Arbox bookings are not reversed. A second organization cannot connect a box that is already connected (409 arbox_box_already_connected).
Telemetry: PostHog arbox_sync_op (op type, status, code, duration, attempts, correlation id) and arbox_sync_run; log lines carry op and correlation ids only. An op that fails with an unexpected (non-Arbox, uncoded) error is parked as sync_failed and also sent to Sentry with its stack, tagged arboxOpType and organizationId. Known outcomes (conflicts, Arbox rejections, outages) are not.
Code map
| Concern | Source |
|---|---|
| API allowlist, header auth, shared rate cap | apps/api/src/arbox-bridge/arbox.transport.ts, arbox-rate-limiter.ts |
| Typed public API, non-production write guard | arbox-v3-api.service.ts |
| Queue, tick, nudge | arbox-sync.queue.ts, arbox-sync.processor.ts, arbox-sync.nudge.service.ts |
| Run order, booking reconcile/dispatch | arbox-sync.service.ts, arbox-sync.policy.ts |
| Owner alerts | arbox-alerts.service.ts, apps/api/src/notifications/templates/arbox-bridge-issue.ts |
| Member and bridge membership mirror | arbox-member-mirror.service.ts, arbox-entitlement.service.ts |
| Schedule import, series ownership, candidates | arbox-schedule-import.service.ts |
| Class worklist, staff map, coach push | arbox-classes.service.ts, arbox-classes.controller.ts |
| Managed-field guard on the schedule editor | class-sessions.service.ts (assertEditable hooks) |
| Capacity arithmetic | arbox-capacity.service.ts, booking/class-session services |
| Owner settings, health, issues | arbox-bridge.controller.ts, arbox-settings.service.ts |
| Spotter: read-only status tool, help articles | apps/api/src/ai/agent/tools/leaves/arbox.tools.ts, apps/api/src/ai/agent/help/help-content.ts |
| Owner UI | apps/web/src/components/settings/arbox-connect.tsx, apps/web/src/components/settings/arbox/ |
| Schema / pending migrations | libs/db/src/lib/schema/arbox-bridge.ts, migrations 0144, 0152 |
Rollout and acceptance
- Migrations
0144and0152apply through the Railway deploy (preDeployCommand) on merge. Never rundb:push. - Connect an owner-authorized public v3 key. Run preflight. Select the class type and turn on schedule import in dry run. Compare class times, rosters, capacity and cancellations against Arbox; map coaches to Arbox staff; decide series ownership.
- Create the ₪0 non-recurring bridge membership type in Arbox; select it and a cancel reason; turn on members and memberships in dry run and review the queued ops and identity conflicts.
- On a disposable box (non-production requires
ARBOX_SANDBOX_ALLOW), follow the QA plan: duplicate and lost responses, full class, user create/dedup, membership assign/cancel, coach push, notification side effects. Record results under TKN-259. - Record the test reference and go live for the pilot only. Exercise disconnect. Watch parked issues, alerts and
bridgeHealthbefore expanding.
Automated integration tests use isolated fixture schemas in a local test database and fake the vendor API. They do not validate the live vendor contract.