SailEver Connect IQ SDK — Plan (rev. 2)¶
Status: Draft for review · Date: 2026-10-08
Scope: sailever-ciq-sdk (new Monkey C barrel), apps/garmin-watchapp, apps/server, docs/
Revision 2 replaces the first draft. It fixes the gaps found in review (§1) and adds the rule that nothing is recorded for SailEver before pairing, and pairing is where the user consents (§3).
0. Goals and non-goals¶
Goals
- Any Garmin watch-app developer can add SailEver track upload for free.
- Uploading always requires pairing with a SailEver account, which brings new users to sailever.com.
- Watch apps only (
type="watch-app"). Data fields are out of scope because of their memory and background limits. - The WatchApp uses exactly the same barrel as partners.
Non-goals (for now)
- Data fields, widgets, background uploads.
- Live position, trip editing or tides as a separate API (tides keep travelling in the segment response).
- A self-service developer portal. Admins issue client keys by hand at first (§5.1).
1. Review findings and how this plan solves them¶
| # | Finding (rev. 1) | Solution (§) |
|---|---|---|
| 1 | Sync code is not self-contained: TrackSession calls TripStore (day totals, paused flag), SyncEngine reads the token from TripStore and Config, views use the app's Rez, Theme, font sheets. |
Decouple inside the WatchApp first, with byte-identical output, then extract (§7, Phase 2). The SDK reports to the host through a listener (§4.4). |
| 2 | The API cannot produce END totals; partner apps have no odometer. | Totals are optional on stopTrack. The SDK keeps its own fallback totals; the server already falls back to distance_source = computed (§4.3). |
| 3 | Lifecycle missing: trip token, pause/resume, discard, crash recovery, stale sessions, storage full, 401, tides. | Full lifecycle API (§4.2). Crash recovery runs inside SailEver.init. |
| 4 | Plan had 4 vessel states; the wire format has 8. | Barrel constants copy VesselState.mc exactly; they are part of the SLTB contract (§4.5). |
| 5 | pairing_requests table does not exist; pairing state lives on devices. |
Migration targets devices and a new client_apps table (§5.2). |
| 6 | Several apps on one watch could collide on devices(platform, device_uid). |
uniqueIdentifier is unique per watch + app (Garmin forum), so every app on a watch is already its own Device row. We make that explicit with devices.client_app_id and show the app name on the Devices page (§5.3). |
| 7 | Attribution was self-declared, could be faked, and there was no kill switch or per-app rate limit. | Partner registry client_apps with an issued client key; the name shown always comes from the registry; status suspended stops pairing and uploads; rate limits per app (§5.1). |
| 8 | tracks already has device_id, source, device_name; the badge fallback mislabelled FIT uploads; English hard-coded. |
Attribution comes through device → client_app; tracks store only a snapshot of the name; the badge is translated and shown only for SDK tracks (§5.4). |
| 9 | Partners compile the barrel into their apps; we cannot push fixes. | Versioned public contract with additive-only rules, sdk_version on every request, golden-file contract tests, deprecation via telemetry (§6). |
| 10 | Shared storage, memory, BLE request queue, System.exit(). |
Reserved key namespace, a storage budget the host sets, one request at a time with a busy flag, the SDK never exits or pops host views (§4.6). |
| 11 | Undefined behaviour when not paired. | Not paired → the SDK writes and sends nothing (§3). |
| 12 | Consent, privacy policy, partner terms missing. | Consent on the watch and the web, stored with a version; partner terms; privacy policy update (§3, §8). |
New findings while reworking the plan:
| # | Finding | Solution (§) |
|---|---|---|
| 13 | Resale leak (exists today): queued files survive unpairing. A buyer who pairs the watch uploads the seller's queued tracks into the buyer's account. | Unpairing or a 401 deletes the queue and the open session. Queue entries carry the pairing generation; entries from another pairing are dropped (§3.3). |
| 14 | Unpairing on the watch is local only: StartMenu calls TripStore.clearDeviceToken() and never POST /devices/unpair, so the token stays valid on the server. |
SailEver.unpair() revokes on the server first (best effort), then deletes locally (§3.3). |
| 15 | POST /devices/register sends device ID, model and version before any consent. |
Superseded 2026-10-19 by ADR 006: the WatchApp sends nothing, registration included, before the user agrees on the watch (consent stored on the watch only). SDK: never calls it; partner apps send nothing before pairing (§3.1). |
| 16 | Updating the WatchApp to the barrel must not lose a paused track, queued files or the token. | The barrel keeps the existing storage key names; upgrade test from the last store release (§4.6, Phase 3). |
2. Repository layout¶
sailever/
├── sailever-ciq-sdk/ [NEW] Monkey C barrel
│ ├── manifest.xml products, permissions: Communications, Positioning
│ ├── monkey.jungle barrel build (barrelbuild)
│ ├── CHANGELOG.md SDK versions, contract changes
│ ├── resources/strings/ en + de (consent, pairing, upload texts)
│ ├── source/
│ │ ├── SailEver.mc public facade (module SailEver)
│ │ ├── Contract.mc VERSION, API base URL, vessel states, limits
│ │ ├── core/Pairing.mc consent, pairing request, poll, token, unpair
│ │ ├── core/Store.mc all Storage access, reserved keys, migration
│ │ ├── core/TrackSession.mc moved from the WatchApp, no TripStore calls
│ │ ├── core/Totals.mc fallback distance / per-state split / timer
│ │ ├── encoder/SltbEncoder.mc moved unchanged
│ │ ├── queue/TrackQueue.mc moved, pairing generation per entry
│ │ ├── sync/SyncEngine.mc moved, one request at a time, busy flag
│ │ └── ui/ plain default views (system fonts only)
│ │ ├── ConsentPairingView.mc
│ │ └── UploadView.mc
│ └── test/ (:test) SltbEncoderTest etc.
├── apps/garmin-watchapp/ uses the barrel; keeps its own themed views
├── examples/minimal-recorder/ reference app, also the barrel's test host in CI
├── apps/server/ client_apps, consent, attribution, contract tests
└── docs/
├── ciq_sdk_plan.md this file
├── ciq_sdk_guide.md partner quick start + API reference
├── adr/004-ciq-sdk-and-pairing-consent.md
└── specs/shared/track-sync.md updated
The WatchApp links the barrel as source during development (base.barrelPath = ../sailever-ciq-sdk/monkey.jungle), so a change to the barrel needs no separate build. Partners get the versioned .barrel file from a release.
3. Pairing, consent and "no data before pairing"¶
3.1 Rules¶
- Not paired → nothing for SailEver. The SDK writes no SLTB, no queue entries, no checkpoints, and makes no network requests.
startTrackreturnsSailEver.NOT_PAIREDand does nothing else. - Consent is given when pairing, in two steps:
- On the watch, before a code is requested: a short screen in the user's language: "Your tracks (positions, times, vessel state) are sent to sailever.com and stored in your SailEver account. sailever.com/privacy" with Agree / Cancel. Only Agree requests a code. This is consent to the transfer itself.
- On the web (
/pair), where the user is known: the page names the app from the registry ("Regatta Pro (by Example GmbH) wants to send your tracks to your SailEver logbook"), lists the data, and needs a checked box before Pair. This is the consent that is stored and can be shown later. - Stored proof:
devices.consented_at,devices.consent_version, anddevices.client_app_id. The consent text is versioned (config/consent_versions.yml); a new text version means a new pairing is required only if the purpose changes (decided per version). - Withdrawal = unpairing, on the watch or on the web (Devices page). Both stop all uploads at once.
- FIT recording in the WatchApp is unaffected. It is Garmin's own activity, saved to Garmin Connect, and is not sent to SailEver.
3.2 WatchApp behaviour (applies before the SDK exists, see Phase 0)¶
| Situation | Behaviour |
|---|---|
| Start new trip while not paired | Shows a hint screen once per trip: "Not paired — this trip is recorded on the watch only and not sent to SailEver." Options: Pair now / Record anyway. "Record anyway" records FIT and the dashboard as today, with no SLTB. |
| Pairing during a paused track | Allowed from the start menu. SLTB begins at Resume; the part before pairing is not sent. |
| Pairing during a running track | Not offered (the start menu is not reachable while recording). |
| Upload menu item | Only shown when paired and files are queued (the "pair first" variant goes away: unpaired means no files). |
| First start after updating to this version | If not paired: delete any SLTB queue, open checkpoint and session (left over from the beta). |
3.3 Unpair, revoke, resale¶
| Event | Watch / SDK action |
|---|---|
| User picks Unpair | Confirmation screen with the number of tracks not sent yet ("3 tracks not sent yet will be deleted"). Then POST /devices/unpair (best effort, one attempt), then delete the token, the queue, the open session and the checkpoint. A running track continues as FIT only. |
Upload gets 401 (revoked on the web, or the watch was paired by someone else) |
Same deletion, silent. The upload view shows "Pairing removed — tracks were not sent". |
| Re-pairing | Each successful pairing increments se_pair_gen. Queue entries store the generation they were written under; SyncEngine drops entries from an older generation instead of sending them. This prevents the resale leak (#13) even if a deletion step was interrupted. |
Change to the current rules: docs/specs/shared/track-sync.md (status table) says "401 → keep file", and the device API spec says "Queued files are kept". Both change to "delete".
3.4 Server side¶
/pair(web): requires the consent box. Storesconsented_at,consent_version. Shows the client app's registry name and publisher.- Devices page: shows the app per device ("fēnix 7 · Regatta Pro"), the consent date, and Remove (= revoke).
create_pairing_request: rejects an unknown or suspended client key with403.- Account deletion keeps cascading as today (tracks, segments, devices).
4. SDK design¶
4.1 Integration (partner view)¶
# monkey.jungle
project.manifest = manifest.xml
base.barrelPath = $(base.barrelPath);barrels/sailever-sdk-1.0.0.barrel
<!-- manifest.xml: the barrel and its required permissions -->
<iq:barrels><iq:depends name="SailEver" version="1.0.0"/></iq:barrels>
<iq:permissions>
<iq:uses-permission id="Communications"/>
<iq:uses-permission id="Positioning"/>
</iq:permissions>
using SailEver;
class RegattaApp extends App.AppBase {
var listener = new RegattaSailEverListener();
function onStart(state) {
// Also closes a crashed track (END reason=2) and runs storage migrations.
SailEver.init({
:clientKey => "ck_live_7f3a…", // issued by SailEver, see §5.1
:appVersion => "2.4.1",
:listener => listener,
:storageBudgetBytes => 48 * 1024
});
}
}
// Start of a race
if (SailEver.isPaired()) {
SailEver.startTrack({ :newTrip => true, :state => SailEver.STATE_SAIL });
}
// Every timer tick or position event (any rate; the SDK samples every 5 s)
SailEver.addFix(Time.now().value(), info.position, info.accuracy, pressureHpa);
// Finish
SailEver.stopTrack({ :distanceM => myOdometerM }); // totals optional
var view = SailEver.createUploadView(method(:onUploadDone)); // or headless upload()
4.2 Public API (v1)¶
| Function | Purpose | Returns |
|---|---|---|
init(options) |
Configure, migrate storage, recover a crashed track. Call in onStart. |
— |
isPaired() |
— | Boolean |
createPairingView(callback) |
Consent screen → code → polling. The callback gets true/false. |
[view, delegate] |
requestPairing(callback) / cancelPairing() |
Headless variant: the callback gets {:code, :expiresIn} then {:status}. Must be preceded by the host's own consent screen showing consentText(). |
— |
consentText() |
Localised consent text for headless hosts. | String |
unpair(callback) |
Server revoke + local deletion (§3.3). | — |
startTrack({:newTrip, :state}) |
New track; :newTrip creates a new trip token, otherwise the current trip continues. Ends a stale paused session first. |
OK, NOT_PAIRED, STORAGE_FULL |
addFix(t, position, accuracy, pressureHpa) |
Feeds a fix. The SDK snaps to the 5 s grid, filters quality below Position.QUALITY_USABLE into SKIP, smooths pressure. pressureHpa may be null. |
— |
setVesselState(state) |
Writes a STATE record. | — |
pauseTrack() / resumeTrack() |
Closes the open segment and keeps the session in storage / continues it. | — |
stopTrack(totals) |
FINAL segment with END reason=0. totals is optional (:distanceM, :sailedM, :motoredM, :motorsailedM, :timerS, …); missing values come from §4.3. |
— |
discardTrack() |
END reason=1 if anything was closed; local deletion. | — |
upload(callback) / isBusy() |
Sends the queue once (no retries), one request at a time. | — |
createUploadView(callback) |
Default progress view. Pops only itself; never calls System.exit(). |
[view, delegate] |
queuedTrackCount(), storageUsagePercent() |
For menus. | Number |
Silent uploads stay as today: 15 s after the first fix, hourly, and on stop. Each is one attempt with no retries and no background task.
4.3 Totals¶
Totals.mcaccumulates great-circle distance between accepted 5 s samples, split by vessel state, and the moving timer. These are used for every total the host does not pass.- The WatchApp passes its odometer totals as today, so its END record stays byte-identical.
- The server already handles both cases (
Ingestion::Derivation:devicevs.computed). The END payload has no spare byte, so totals the SDK computed itself are flagged with a newEXTrecord (type = totals_source, value1= computed on watch) written just before END. Decoders that don't know it skip it (spec §4), so this is an additive change (§6.2). The server then prefers its own computed distance for these tracks.
4.4 Listener (host callbacks)¶
All optional; the SDK checks listener has :onX before calling.
| Callback | When | WatchApp uses it for |
|---|---|---|
onSegmentClosed(kind, deltaTotals) |
A segment was closed (first, hourly, cap, pause, stop). | Day totals (replaces the TripStore.addDayTotals call in TrackSession). |
onTrackDiscarded(totals) |
After discardTrack. |
TripStore.subtractDayTotals. |
onTides(tides) |
The segment response carried tides. | TideService. |
onPairingChanged(paired) |
Paired, unpaired, revoked (401). | Refreshing the start menu. |
onStorageFull() |
The budget is used up; recording for SailEver stops. | Alert overlay. |
4.5 Contract constants¶
Vessel states copy VesselState.mc and the SLTB spec exactly: ENGINE=0, MOTORSAIL=1, SAIL=2, ANCHOR=3, BUOY=4, MOORING=5, SHORELINE=6, ADRIFT=7. Labels and colours stay in the WatchApp.
4.6 Running inside someone else's app¶
| Concern | Rule |
|---|---|
| Storage keys | The SDK owns sltb_*, se_* and the queue's file keys; the guide lists them as reserved. Existing WatchApp key names stay unchanged, so an update keeps the paused track, the queue and the token. device_token moves to se_token through a one-time migration in Store.mc. |
| Storage size | The host sets :storageBudgetBytes (default 48 KB, WatchApp keeps 64 KB). |
| Memory | Code size per device is measured in CI with the example app on the smallest supported target; the guide states the peak heap the SDK needs. |
| Network | One request at a time. isBusy() lets the host avoid its own requests during an upload. -101 (BLE queue full) counts as "try at the next save point". |
| Views | The SDK pushes and pops only its own views and never calls System.exit(). The WatchApp keeps its themed SyncView / PairingView on top of the headless API; the default views use system fonts only. |
| Build flavours | :debug / :release annotations for the API URL get verified in the Phase 0 spike. Fallback: init({:endpoint => …}) accepted only with a ck_test_… key. |
| Products | The barrel manifest lists every product the WatchApp supports; a host can target a subset. |
5. Backend (apps/server)¶
5.1 Partner registry¶
create_table :client_apps, id: :uuid do |t|
t.string :name, null: false, limit: 60 # shown to users
t.string :publisher, null: false, limit: 100
t.string :key_digest, null: false, index: { unique: true } # SHA-256 of ck_…
t.enum :status, enum_type: :client_app_status, default: "active" # active, suspended
t.boolean :first_party, null: false, default: false
t.string :contact_email
t.integer :rate_limit_per_hour, null: false, default: 120
t.timestamps
end
- An admin creates a client app (admin area), which issues a
ck_live_…key once. - The key identifies the app, it does not secure it. It can be pulled out of a
.prg. Security still rests on the user's pairing and consent. The key gives attribution, a kill switch and per-app limits. suspended: new pairing requests are rejected and uploads get403 {"error":"client_suspended"}; the SDK keeps the files (the app may be reactivated) and shows "Upload not possible — contact the app maker".- The WatchApp is seeded as
first_party: true. Requests without a client key (WatchApp releases in the field) are treated as first-party, so existing installs keep working (§6.3).
5.2 Migration¶
add_reference :devices, :client_app, type: :uuid, foreign_key: true, index: true
add_column :devices, :consented_at, :datetime
add_column :devices, :consent_version, :string, limit: 20
add_column :devices, :sdk_version, :string, limit: 20
add_column :tracks, :client_app_name, :string, limit: 60 # snapshot for the badge
No pairing_requests table: pairing state stays on devices.
5.3 Endpoints¶
- All SDK requests send
X-SailEver-Client: ck_live_…andX-SailEver-SDK: 1.0.0. create_pairing_request: resolves the client app from the header (or first-party when missing), stores it on the device.TrackSegmentsController: checks the device's client app status; storessdk_version; copiesclient_app.nameintotracks.client_app_namewhen the track is created. No new body parameters.- Rate limits: per device as today, plus
client_apps.rate_limit_per_hour × active devicesper app. DevicesController#unpairalready exists; the WatchApp starts calling it.
5.4 UI¶
- Track and trip pages: a translated badge "Recorded with Regatta Pro" only when
tracks.client_app_nameis set and the app is not first-party. Nothing for the WatchApp, FIT uploads or older tracks. - Devices page: app name per device, consent date.
/pair: consent box, registry name and publisher (§3.4).
6. Versioning and the public contract¶
Answer to "is /api/v1 enough?" It is the right base, but only one of four parts of the contract. Once a partner app ships, the barrel inside it never changes. So the server must accept what that barrel sends for as long as anyone runs that app.
6.1 What is the contract¶
| Part | Version marker | Already there |
|---|---|---|
| HTTP endpoints, request and response JSON | URL /api/v1/… |
yes |
| SLTB binary format | version byte in each segment header + format_version in the envelope; EXT records for additive fields |
yes (docs/specs/shared/track-sync.md) |
| SDK behaviour (when it closes and uploads, how it reacts to status codes) | X-SailEver-SDK header |
new |
| Client identity | X-SailEver-Client header |
new |
6.2 Rules within v1¶
- Additive only. New optional request fields, new response fields, new EXT record types. Old SDKs ignore what they don't know; the server never requires a new field.
- Never change the meaning of a status code, a field or a vessel state ID. The status table in
docs/specs/shared/track-sync.md(status table) is frozen; new cases get new codes or newerrorvalues that old SDKs treat like the closest existing case. - Breaking change → new major.
/api/v2and SLTBversion = 2run next to v1. The server keeps every decoder it ever accepted (already in the spec). - Pairing endpoints are contract too (
pairing_requests,poll,unpair), includingpoll_interval_seconds, which the server can raise to slow clients down.
6.3 Making it safe¶
- Golden-file contract tests in Rails: each SDK release adds recorded requests (pairing, segments incl. first/hourly/final/discard/recovered, unpair) to
test/fixtures/contract/sdk-<version>/. CI replays all versions against the current server. A server change that breaks any released SDK fails CI. - Telemetry:
devices.sdk_versionandlast_seen_atshow which versions are still in use; an admin chart per client app and version. - Deprecation: v1 is shut down only after 12 months with no traffic (or after notice to partners). No forced upgrade: old apps cannot be updated by us, and a hard
426would only lose user data. - SDK semver: patch = bug fix; minor = new optional API; major = breaking API for the host app (not the wire). Wire changes follow §6.2 independently.
7. WatchApp changes¶
- Phase 0 (ships alone, before the SDK): consent screen before pairing (built 2026-10-19 as a local gate, ADR 006; no web consent per device, no server unpair call), no SLTB when unpaired, delete on unpair/401, server unpair call, pairing generation on queue entries, cleanup on first start (§3.2, §3.3). This fixes findings 13 and 14 for current users.
- Phase 2 (decouple in place):
TrackSessionstops callingTripStoreand reports through the listener;SyncEnginegets the token from a store object;Config.API_BASE_URLmoves to aContractmodule. Output must be byte-identical:SltbEncoderTest,E2EAutopilot,PausedTrackTestpass unchanged, and a recorded simulator run produces the same SLTB files before and after. - Phase 3 (switch to the barrel): delete
source/sync/,DeviceRegistrationstays in the WatchApp (first-party telemetry).TripStoredrops token and uid keys.PairingViewandSyncViewstay as themed views on top of the headless API.
8. Legal and partner obligations¶
- Privacy policy (sailever.com/privacy): data received through partner apps; consent and withdrawal; the registry name is the only data about the partner app shown; retention as in
docs/specs/server/data-model.md. - SDK terms (accepted when a key is issued): partners must not change the consent text, must not call pairing without showing it, must mention SailEver in their store description and privacy policy, and accept suspension for misuse.
- Licence of the barrel and the example app (suggestion: proprietary licence with free use; the example app MIT).
- Garmin store: partner apps list the Communications permission and the data transfer to SailEver in their description.
9. Docs to update¶
docs/adr/004-ciq-sdk-and-pairing-consent.md: new ADR (registry, consent, no data before pairing, contract rules).docs/specs/shared/track-sync.md: status table (401 → delete), headers, ENDtotals_source, pairing generation, client suspended.docs/adr/003-…: device = one app on one watch;client_app_id.docs/specs/watchapp/sync-and-pairing.md,docs/specs/shared/device-api.md: unpaired behaviour, "queued files are kept" → deleted.docs/glossary.md: SDK (the SailEver barrel), Client app (a partner app using the SDK; avoid: partner app in the UI, integration), Consent (given when pairing). Devices page wording: device stays.apps/garmin-watchapp/README.md: pairing and consent flow.
10. Roadmap¶
| Phase | Content | Done when |
|---|---|---|
| 0 — Consent and spike | WatchApp: §3.2/§3.3 changes. Spike: minimal barrel with a string resource, :debug/:release annotations and a (:test) test, built into the example app on the smallest target. |
WatchApp released; the spike answers the open build questions in §4.6. |
| 1 — Backend | client_apps, migration, headers, /pair consent, Devices page, badge, suspension, rate limits, contract tests for the current WatchApp. |
bin/quality --ci and bin/rails test green; requests without headers behave exactly as today. |
| 2 — Decouple in place | §7.2. | Byte-identical SLTB output; all watch tests pass. |
| 3 — Barrel | Extract to sailever-ciq-sdk, WatchApp consumes it, storage migration, memory measured per target. |
Upgrade test from the last store release keeps a paused track, queued files and the token; no compiler warnings. |
| 4 — Partners | Example app, ciq_sdk_guide.md, terms, first partner key, contract fixtures for SDK 1.0.0. |
A fresh developer integrates the example in under an hour following only the guide. |
11. Open decisions¶
- "Record anyway" when unpaired (§3.2): offer it (recommended, the app stays useful on deck) or block starting a trip until paired?
- Consent text changes: require re-pairing only when the purpose changes (recommended), or on every new text version?
- Partner suspension: keep queued files (recommended) or delete them?
- Licence for the barrel and the example app.