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:
- the protobuf messages in
cairn.worker.v1(committed stubs in each worker repo), and - 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 onCAIRN_JOBS/CAIRN_WEBHOOKSand the presence, manifest and rate-limit KV buckets. The full list is on Self-host → Providers. Anything else is refused withPermissions Violation. - Identity: provider (e.g.
garmin), package (stable build identity, e.g.cairn.workers.garmin) and the parser version (see Versioning). Every publishedJobResultMUST carryworker_name,worker_version(the parser versionN.M) andworker_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):
| Version | Form | Meaning | Bumped when |
|---|---|---|---|
| Release | v0.6.0 | the core contract the worker implements | every release; major.minor follows core |
| Dataset | N | format of the archived raw blob (version in the archive envelope) | a parser can no longer read the previous format |
| Parser | N.M | parser for dataset N, output revision M | every 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.3worker withv0.5.0core); 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.4worker withv1.3.0core works;v1.4.0withv1.3.0does not). Core publishes its release underinfoin thecairn_coreKV bucket; a worker MUST refuse to run against an incompatible core. Dev builds are exempt. - Parser
N.xMUST read every datasetNarchive, including archives written by older importers; fields missing from them are absent, not an error. It does not read other dataset versions. - Bump
Mon 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 resetsM:1.4→2.0. - Forward only. A source parsed by
N.Mis only re-parsed or re-fetched by a parser>= N.M. Core rejects results that would lower a source's version; a worker given aparse_blobjob with a highermin_parser_versionthan its own MUST NAK it so a newer pooled instance takes it. - A legacy bare integer
Nreads asN.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. source | Offered |
|---|---|
same N.M | re-parse (re-run) |
same N, higher M | re-parse from archive (no provider quota) |
higher N | re-fetch from provider |
| older | nothing — 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:
| Subject | Required | Purpose |
|---|---|---|
cairn.jobs.fetch_source.<provider>.<worker_name> | MUST | import one activity |
cairn.jobs.reconcile.<provider>.<worker_name> | MUST | catch new activity since watermark |
cairn.jobs.parse_blob.<provider>.<worker_name> | MUST if raw blobs are archived | quota-free re-parse |
cairn.jobs.import_metrics.<provider>.<worker_name> | MUST if manifest declares METRIC events | health metrics |
cairn.jobs.import_gear.<provider>.<worker_name> | MUST if manifest declares Gear | the account's full gear list (0.4.0) |
cairn.jobs.backfill.<provider>.<worker_name> | MAY | legacy 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>.
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'sCAIRN_NATS_JOB_ACK_WAITdoes 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.
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):
- Marshal the full
JobResult(protojson). - Request a presigned PUT via
cairn.blobs.presign_upload.<provider>with"kind": "result",content_type,content_lengthand the hexcontent_sha256of the body. Core mints the key under the subject's provider; auser_idin the request must hold an account of that provider. The returnedblob_idis the full key — hand it back verbatim (raw_blob_id, attachments,presign_download,payload_ref): core refuses keys outsideusers/<user>/<provider>/…,shared/<provider>/…and (for results)transfer/<provider>/…. - PUT the body to the returned URL, sending
required_headersverbatim. - Publish only the envelope: worker stamp +
new_watermark/more_availablepayload_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.
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), andfailed_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_sourcesub-jobs for unknown ext-ids, capped bymax_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. Advancinglast_sync_atis what stops the scheduler from re-polling the account every tick; core also appliesnew_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/passwordaliases until 0.8). The account named in the body must belong to the subject's provider, or core answersaccount_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 reportRateLimitSnapshots 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.