Skip to main content
Version: 0.7.2

Provider worker contract

Every provider worker (strava, garmin, yours) is a standalone process in its own repo — see Provider workers. The only contract with cairn-core is:

  1. the protobuf messages in cairn.worker.v1 (the provider SDKs ship the stubs; a worker without an SDK generates its own), and
  2. the NATS subjects + behaviors specified here.

This page is normative: MUST items are required for a worker to be compliant — core's scheduler, import queue, and UI assume them. Run the conformance harness (§10) against a live worker to verify mechanically.

Wire conventions: job payloads (server → worker) are hand-rolled JSON; results (worker → server) are protojson-encoded cairn.worker.v1.JobResult. All job subjects live on the CAIRN_JOBS work-queue stream; all result subjects on the CAIRN_RESULTS interest stream — see the pipeline.

1. Identity and connection​

  • The worker connects to NATS with an enrollment token (production, auth-callout) or static/anonymous credentials (dev). The admin sets the provider on the enrollment; any well-formed type ([a-z0-9_-], 1–48 chars) is accepted, so a new provider's worker can enroll without a core change.
  • Under auth-callout the worker MUST stay within core's standard permission template: the exact job and result subjects of §3–§7 for its provider, its own cairn.workers.<worker_key>.>, cairn.cmd.worker.<worker_key>.> and webhook subjects, and the JetStream API subjects for its pull consumers on CAIRN_JOBS / CAIRN_WEBHOOKS and the presence, manifest and rate-limit KV buckets. The full list is on Self-host → Providers. Anything else is refused with Permissions Violation.
  • Identity: provider (e.g. garmin), package (stable build identity, e.g. cairn.workers.garmin) and the parser version (see Versioning). Every published JobResult MUST carry worker_name, worker_version (the parser version N.M) and worker_package.
  • Worker request/reply subjects are provider-scoped on both sides (0.7): core takes the provider from the subject a worker asked on and refuses accounts and blob keys of other providers in the body.

Manifest and provider meta​

Write a JSON manifest to the cairn_worker_manifests KV bucket under the key <worker_name> at startup and again within its 15 min TTL:

{"worker_name": "...", "version": "<parser N.M>", "provider": "...",
"package": "...", "updated_at": "<RFC3339>",
"provider_meta": {
"display_name": "Strava",
"activity_url_template": "https://www.strava.com/activities/{id}",
"setup_docs_url": "https://...",
"auth_method": "oauth2",
"oauth": {"authorize_url": "https://www.strava.com/oauth/authorize",
"scopes": ["read", "activity:read_all"], "scope_separator": ",",
"pkce": false, "authorize_params": {"approval_prompt": "force"}},
"config_fields": [
{"key": "client_id", "label": "Client ID", "help": "..."},
{"key": "client_secret", "label": "Client secret", "secret": true}]}}

version, provider and package drive update-available and the pool registry (§3). provider_meta — MUST for a pool that links accounts (0.7): core compiles no provider knowledge in; it stores the meta, bound to the first package that published it for the provider (a different package's meta is logged and ignored until an admin unbinds the provider, DELETE /api/admin/providers/{provider}), and builds the connect form and the authorize URL from it. auth_method is oauth2 (core runs the authorize round-trip, the pool exchanges the code — §9b) or credentials (the connection's client_id/client_secret slots hold a login, delivered by cairn.creds.<provider>.get). config_fields label the two slots; only client_id and client_secret exist. URLs must be https (http only on a loopback host). oauth.pkce makes core send an S256 challenge and hand the verifier to the exchange; authorize_params are added to the authorize URL verbatim (client_id, redirect_uri, response_type, scope, state and the PKCE parameters are core's). Parser-only pools (file importers, re-parsers) omit provider_meta.

Versioning​

A worker carries three independent versions (from 0.5.0):

VersionFormMeaningBumped when
Releasev0.6.0the core contract the worker implementsevery release; major.minor follows core
DatasetNformat of the archived raw blob (version in the archive envelope)a parser can no longer read the previous format
ParserN.Mparser for dataset N, output revision Mevery change to the parsed output
  • Release compatibility: before 1.0 a worker is compatible with core when both releases share major and minor (v0.5.3 worker with v0.5.0 core); patch levels are free. From 1.0 on, minor and patch releases are backwards compatible: same major, and the worker's minor not above core's (v1.1.4 worker with v1.3.0 core works; v1.4.0 with v1.3.0 does not). Core publishes its release under info in the cairn_core KV bucket; a worker MUST refuse to run against an incompatible core. Dev builds are exempt.
  • Parser N.x MUST read every dataset N archive, including archives written by older importers; fields missing from them are absent, not an error. It does not read other dataset versions.
  • Bump M on any change to the parsed output (new field, fixed mapping, changed unit). A refactor with byte-identical output does not bump it. A new dataset version resets M: 1.4 → 2.0.
  • Forward only. A source parsed by N.M is only re-parsed or re-fetched by a parser >= N.M. Core rejects results that would lower a source's version; a worker given a parse_blob job with a higher min_parser_version than its own MUST NAK it so a newer pooled instance takes it.
  • A legacy bare integer N reads as N.0.
  • The archive MUST hold every input the parser uses, so a re-parse reproduces the import. A parser change needing a new input (e.g. a separate gear call) first archives it — additive, still dataset N; older archives lack it and need one re-fetch.

What core offers per source, given the live workers:

Live worker vs. sourceOffered
same N.Mre-parse (re-run)
same N, higher Mre-parse from archive (no provider quota)
higher Nre-fetch from provider
oldernothing — would downgrade

2. Presence heartbeat — MUST​

Write a JSON presence record to the cairn_worker_presence KV bucket every ~20s under the key <worker_name>.<instance_id>:

{"worker_name": "...", "worker_key": "...", "instance_id": "...",
"version": "<parser N.M>", "release": "<vX.Y.Z>", "provider": "...", "package": "...",
"webhooks": false, "capabilities": {...}, "last_seen": "<RFC3339>"}

Core uses presence for the worker-admin UI, connect-flow gating, and manage-view liveness.

3. Job consumption — MUST​

Jobs are routed to pools (from 0.5.0): every instance with the same worker_name for a provider is one pool, and core publishes each job to one pool on cairn.jobs.<job>.<provider>.<worker_name>. An external account is assigned to one pool; its imports, re-fetches, gear and metrics jobs go there. Re-parse goes to a pool of the source's own package. Several pools (e.g. an API importer and a file importer) can serve one provider side by side: the user connects the same provider account once per pool they want, each connection with its own sync state, tokens and queue. Rate-limit budgets are per provider app: the token reply carries app_id (a hash of the connection's client id), and the worker keys its buckets <provider>:<app_id>:<window> (stored as <provider>.<app_id>.<window> in the cairn_rate_limits KV). A token without app_id (older core) uses the pool's bucket <provider>:<worker_name>:<window>. Connections of one provider account on different pools MUST use different apps. Core groups the resulting sources into one activity and merges them by its priority list, where <provider>.<worker_name> names one pool. A pool name is permanent: core binds it to the first package it reports and refuses another package under that name, so a different implementation needs a new name.

Create a durable pull consumer on CAIRN_JOBS for every job subject of your pool, filtered on the exact subject. CAIRN_JOBS is a work queue: it refuses two consumers with the same filter, so instances of one pool share one durable per subject. The permission template only allows filtered consumer creation to these subjects:

SubjectRequiredPurpose
cairn.jobs.fetch_source.<provider>.<worker_name>MUSTimport one activity
cairn.jobs.reconcile.<provider>.<worker_name>MUSTcatch new activity since watermark
cairn.jobs.parse_blob.<provider>.<worker_name>MUST if raw blobs are archivedquota-free re-parse
cairn.jobs.import_metrics.<provider>.<worker_name>MUST if manifest declares METRIC eventshealth metrics
cairn.jobs.import_gear.<provider>.<worker_name>MUST if manifest declares Gearthe account's full gear list (0.4.0)
cairn.jobs.backfill.<provider>.<worker_name>MAYlegacy full-history walk

The pre-0.5 job subjects without <worker_name> are no longer granted (0.7). Results and lookups are pool-scoped only. Workers publish their own fetch_source sub-jobs (reconcile, webhooks) to their own pool subject and their results to cairn.results.<job>.<provider>.<worker_name>.

Why "every subject" is a MUST

CAIRN_JOBS is a work-queue stream — messages for a subject nobody consumes are retained forever. A worker without a reconcile consumer leaves the scheduler's reconcile jobs queued indefinitely and new activities are never auto-imported.

Ack discipline (the delivery semantics core assumes):

  • Success → ack.
  • Transient failure (network, 5xx, DB blip) → NAK; provider rate limit → NAK with delay anchored to the limit window.
  • Poison (bad payload, activity gone, unmappable data) → publish a failure envelope (§6) then Term.
  • Long-running handling MUST extend the ack deadline (in_progress) on a cadence below the AckWait of the job consumer the worker creates (2 min in the Strava worker, 30 s in the Garmin worker; core's CAIRN_NATS_JOB_ACK_WAIT does not apply to worker-created consumers): heartbeat every fetched message from batch arrival until its disposition, otherwise JetStream redelivers mid-processing and the activity is re-imported on every redelivery.
  • Bound retries: Term (with failure envelope) after ~5 deliveries of a persistently failing job rather than NAK-looping forever.

4. Job payloads (server → worker, JSON)​

fetch_source:

{"job_id": "…", "account_id": "<external_account uuid>", "user_id": "<uuid>",
"provider": "…", "ext_id": "…", "fetch_streams": true,
"reason": "backfill|webhook|reconcile"}

reconcile:

{"job_id": "…", "account_id": "…", "user_id": "…", "provider": "…",
"watermark": "<RFC3339, optional>", "max_enqueue": 500,
"known_ext_ids": ["…", "…"]}

import_gear (0.4.0): {"job_id", "account_id", "user_id", "provider", "reason"} — answer with one GEAR event per gear on the account, retired included. Core publishes it on account connect, on the user's "Sync gear" and alongside every reconcile, only when a live worker for the provider declares Gear in its manifest.

import_metrics: {"job_id", "account_id", "user_id", "provider", "since_unix", "until_unix"} — answer with METRIC events for the window (metric events). Core publishes it on the user's "Import health" and, from 0.7, once a day per account alongside reconcile (a day of overlap, 30 days on the first run), only when the account's pool declares a metric data type (Steps, HRV, RestingHR, Sleep, Weight, WaterIntake).

parse_blob: as fetch_source plus "blob": {"url": "<presigned GET>", "expires_at": "…", "fallback_handle": "<blob key>"} and "min_parser_version": "N.M" (the source's current parser version; NAK when yours is lower or reads another dataset) — on an expired URL, re-presign via cairn.blobs.presign_download.<provider> with the fallback handle.

5. Results — claim-check — MUST​

Results are protojson JobResult published to cairn.results.<job_type>.<provider>.<worker_name> (reconcile appends the account id). Core checks that worker_name in the result equals the pool token of the subject, that the name's package never changes, and that an activity result comes from the account's pool (fetch_source) or from the package that imported the source (parse_blob). The pre-0.5 cairn.results.<job_type>.<provider> form is no longer granted to workers.

Event-carrying results MUST be claim-checked (streams routinely exceed the broker's max_payload; inline oversized publishes used to Term and wedge the import queue):

  1. Marshal the full JobResult (protojson).
  2. Request a presigned PUT via cairn.blobs.presign_upload.<provider> with "kind": "result", content_type, content_length and the hex content_sha256 of the body. Core mints the key under the subject's provider; a user_id in the request must hold an account of that provider. The returned blob_id is the full key — hand it back verbatim (raw_blob_id, attachments, presign_download, payload_ref): core refuses keys outside users/<user>/<provider>/…, shared/<provider>/… and (for results) transfer/<provider>/….
  3. PUT the body to the returned URL, sending required_headers verbatim.
  4. Publish only the envelope: worker stamp + new_watermark/more_available
    • payload_ref {blob_id, size_bytes, content_type, content_sha256}, no inline events. Core fetches the object, ingests, and deletes it; a bucket lifecycle rule reaps orphans after ~1 day.

Event-free results (reconcile watermark, backfill stamps) publish inline; import_gear results are small and MAY publish inline too.

Gear events (0.4.0) — MAY: any result may carry WORKER_EVENT_TYPE_GEAR events (ImportedGear{ref, payload: GearImport}), keyed by (external_account_id, external_id). They precede the activity that references them, like segments. The activity's ActivitySourcePayload.gear_external_id names the provider's gear id and gear_name is a display hint; the id MAY reference gear core has not seen yet (it is resolved once the gear arrives). Core never creates gear from a GEAR event on its own: an unknown record is stored pending and the user decides. Gear is optional data: a worker MUST NOT fail or delay an activity import because a gear lookup failed, and ref.user_id must be the owner of external_account_id (core rejects a mismatch).

Publish routing — SHOULD: results and fetch_source sub-jobs go to JetStream streams (CAIRN_RESULTS, CAIRN_JOBS) and need a JetStream publish with a Nats-Msg-Id; token, credential, presign and discover subjects are core NATS request/reply. Decide per subject before publishing — look up whether a stream covers it ($JS.API.STREAM.NAMES with a subject filter, granted by the template) and cache the answer — instead of trying a JetStream publish first: a JetStream publish to a core-only subject waits for an ack that never comes and delivers twice on retry.

Presign/upload failures are transient (NAK + retry) — never drop a result body silently.

Message-ids (JetStream dedup): activity results result:<job_id>:d<delivery_attempt> (the attempt suffix lets a deferred retry re-publish past the dedup window; ingest is idempotent), reconcile and import_gear results result:<job_id>, fetch_source sub-jobs enqueued by reconcile reconcile:<provider>:<account_id>:<ext_id>.

Stream hygiene — SHOULD: emit strictly unique, ordered sample timestamps. Core dedupes defensively (last sample wins), but providers do ship duplicate samples around pauses — clean them at the source.

Metric events​

An import_metrics result carries WORKER_EVENT_TYPE_METRIC events (ImportedMetric{ref, key, ts, value_numeric | value_json}). Set exactly one value: value_numeric for a scalar, value_json (a JSON document) for a structured value such as sleep_stages. Core stores the metric by its key:

keyStored inServed by
a time-series data type: Steps, HRV, RestingHR, Sleep, Weight, WaterIntakehealth series, merged per day across providersweb /health, GET /api/health?type=, ListHealthSeries
a reserved key (below, from metrics.proto)metrics storeGET /api/metrics, MetricsService
a provider-namespaced key <provider>.<name>, e.g. whoop.recovery_scoremetrics storeGET /api/metrics, MetricsService

Reserved keys; all numeric except sleep_stages, which takes value_json ({"stages": [{"start", "end", "stage"}]}):

GroupKeys
Body compositionweight_kg, body_fat_pct, lean_mass_kg, bone_mass_kg, hydration_pct, muscle_mass_kg
Cardiovascularresting_hr_bpm, max_hr_bpm, hrv_rmssd_ms, hrv_sdnn_ms, blood_pressure_systolic_mmhg, blood_pressure_diastolic_mmhg, spo2_pct
Activity rollupssteps, floors_climbed, active_minutes, calories_burned_kcal
Sleepsleep_total_min, sleep_deep_min, sleep_rem_min, sleep_light_min, sleep_awake_min, sleep_score, sleep_stages
Performancevo2max_ml_kg_min, ftp_cycling_w, lthr_running_bpm, lthr_cycling_bpm, threshold_pace_running_s_per_km, css_swimming_s_per_100m, critical_power_w, w_prime_kj
Training load (core computes these)tss, ctl, atl, tsb
Wellnessmood, energy, soreness, stress, motivation

A metrics-store key matches ^[a-z0-9][a-z0-9_.]*$ (at most 64 characters). Core stores any such key, but a worker SHOULD send a reserved key or namespace its own as <provider>.<name>.

  • ref.external_id SHOULD be a stable per-sample id: re-imports upsert on (user, key, ts, provider, external_id), so a re-run of the window adds no rows. A metrics-store event without one is keyed derived:<key>@<ts>.
  • Metrics-store rows record the result's worker_name and worker_version as their source.
  • ref MUST carry the job's user_id, provider and external_account_id: core stores a metric only for an account of that user and provider that the result's pool serves, like activities and gear.
  • Core refuses a metric with an invalid key, the wrong shape for a reserved key (value_json for a numeric key, value_numeric for sleep_stages), no or both values, invalid JSON, a malformed user_id, or a missing or foreign external_account_id. A refusal is logged and counted (cairn_worker_metrics_refused_total) and does not fail the result's other events.

6. Failure envelope — MUST​

Before Terming a job that can never succeed, publish a small JobResult carrying only:

  • error: WorkerError {class, code, message} (no events), and
  • failed_ref: ExternalRef {user_id, provider, external_account_id, external_id} for import-queue correlation.

Core fails the queue item immediately with the true reason. Without it the item sits in_progress until the stale reaper masks the cause as stale: no result after N dispatch attempts.

7. Reconcile behavior — MUST​

  • Window: list from max(watermark − 1h, now − 30d) — never the full history (that is the user-driven import queue's job).
  • Enqueue fetch_source sub-jobs for unknown ext-ids, capped by max_enqueue, with the deterministic msg-ids above.
  • Publish the result to the account-suffixed subject cairn.results.reconcile.<provider>.<external_account_id> — and publish it even when nothing new was found. Advancing last_sync_at is what stops the scheduler from re-polling the account every tick; core also applies new_watermark (advance-only) from it.
  • The scheduler's target cadence is CAIRN_SCHEDULER_POLLING_INTERVAL (default 60m, per-connection override). Workers SHOULD coalesce reconcile jobs per account (~10 min) so a scheduler backlog drains as cheap acks.

8. Discover — MUST​

Subscribe to and respond on plain request/reply cairn.discover.<provider>.<worker_name> (the template allows subscribing to it); core asks the account's pool:

Request: {"account_id": "…", "start_page": 1} Reply: {"items": [{"item_type": "activity", "external_id": "…", "item_time": "<RFC3339>"}], "next_page": 2} — or "complete": true at the end, "rate_limited": true when the budget is exhausted mid-walk, or {"error": "…"}. Never return an empty success when the real cause was an error or rate limit — the UI would show "0 importable activities".

9. Credentials & rate limits​

  • Fetch credentials fresh per job over request/reply — OAuth providers: cairn.tokens.<provider>.get (+ .store, .needs_reauth); credential providers: cairn.creds.<provider>.get, which replies {"client_id", "client_secret"} (the connection's slots; email/password aliases until 0.8). The account named in the body must belong to the subject's provider, or core answers account_gone. Never persist credentials in the worker beyond in-memory caches.
  • Workers hold zero S3 credentials — all blob I/O goes through presigned URLs (§4/§5).
  • Rate-limited providers: reserve from the shared budget KV (cairn_rate_limits) before each API call, sync the limiter from the provider's usage headers on every response, anchor 429 backoff to the real window reset, and report RateLimitSnapshots opportunistically.

9a. Account lookup (webhooks)​

A webhook event names the provider's owner id, not a Cairn account. Resolve it with request/reply on your pool's subject cairn.accounts.lookup.<provider>.<worker_name>:

{"external_id": "<owner id>", "subscription_id": "<optional>"}

Reply: {"account_id": "…", "user_id": "…"} or {"error": "not_found" | "auto_import_suspended" | …}. subscription_id is preferred when known. The same provider account may be connected once per pool; core returns the connection of the pool named in the subject (a connection without a pool matches any). The pre-0.5 cairn.accounts.lookup_by_provider_ext responder is gone (0.7).

Webhook deliveries reach core on POST /webhooks/<worker_key> (the pool's <worker_name>-<provider>); core forwards them to cairn.webhooks.<worker_key>.event and relays GET handshakes to cairn.webhooks.<worker_key>.verify.

9b. OAuth code exchange — MUST for auth_method: oauth2​

Core runs the authorize round-trip from the manifest's oauth block and then asks the pool to exchange the code, on plain request/reply cairn.oauth.<provider>.<worker_name>.exchange (the template allows subscribing to it):

Request:

{"connection_id": "…", "client_id": "…", "client_secret": "…",
"code": "…", "redirect_uri": "https://<instance>/auth/oauth/<provider>/callback",
"code_verifier": "<PKCE, when declared>"}

Reply: {"access_token", "refresh_token", "expires_at": "<RFC3339>", "scope", "token_type", "provider_account_id"} or {"error": "exchange_failed" | "bad_request" | "transient", "message": "…"}. provider_account_id is the provider's stable id of the account (Strava's athlete id); core keys webhook lookups and the one-holder rule on it, so fetch it when the token response does not carry it. Core stores the reply as the account's token state; the worker keeps nothing. Answer within 20 s.

10. Conformance harness​

cairn-core ships a checker that verifies the mechanical parts of this contract against a running worker on a dev/staging NATS:

# from a cairn-core checkout
go run ./cmd/worker-conformance --nats nats://localhost:4222 --provider garmin

It asserts: presence heartbeat exists and is fresh; durable consumers exist on CAIRN_JOBS for every required subject (catches the §3 incident class); discover answers with a valid shape; and (with --active) a synthetic reconcile job round-trips to an account-suffixed result. Non-zero exit on any violation — wire it into the provider repo's CI against its dev stack where possible.