How it works
Collect, keep the raw payload, normalize, serve. One binary, three roles, one database.
Your iOS app uploads gzip NDJSON parts.
OAuth pulls and push notifications.
Every payload kept verbatim, on disk or S3, never rewritten.
Map, convert units, validate, de-duplicate, roll up local days.
One canonical model, scoped by tenant and user.
Described by OpenAPI 3.1.
Read only, for AI agents.
Collect
Data arrives two ways. A device upload: your iOS app posts HealthKit data to
POST /v1/ingest/apple-health as gzip NDJSON parts, with a user token that has the upload
scope. A provider pull: for an OAuth provider such as Withings or Oura, the server pulls each active
connection when the provider sends a notification to /inbound/{provider}, and on a backstop
schedule in case one was lost (background sync).
Keep the raw payload
Before anything else, the payload is written to the raw store (a local folder or any S3 compatible bucket), verbatim, as gzip NDJSON. A raw object is never rewritten. In the same transaction as its registry row, a job is queued in Postgres.
Normalize
The etl role maps each line to the canonical model, converts units,
validates values against plausible ranges, drops duplicates by content hash, attributes the
source and device, and rolls up daily summaries per local calendar day.
Serve
Reads are GET /v1/users/{id}/... with the tenant key, paged with a cursor. The
MCP server reads the same data, read only. Both go through one query
layer that always filters on tenant and user.
Three roles, one binary
ohs runs up to three roles, set by OHS_ROLES, in one process or split across several.
| Role | Runs |
|---|---|
api | The HTTP server: REST API, uploads, OAuth callbacks, provider notifications, MCP, health and metrics. |
collect | The pull scheduler and OAuth token refresh. |
etl | The ETL workers, user deletions, and the upkeep jobs (orphan scanner, drainer, retention). |
One process with all three is the simplest deployment, and the default. To scale, run several
api instances behind a proxy and a separate etl process. The queue lives in Postgres
(FOR UPDATE SKIP LOCKED): no message broker, cache or worker fleet.
Always run etl somewhere
Uploads are processed and user deletions are carried out by the etl role. A deployment that
runs api without etl anywhere accepts both and never finishes them.
Why keep the raw payload
Because it makes mistakes cheap. When a mapper is fixed (a new type mapped, a unit corrected), any user can be re-processed from their raw files, and records are de-duplicated by content hash, so nothing is stored twice. A crash between writing the raw file and queuing its job is caught by the orphan scanner.
The full design, with storage schemas and reliability details, is in Architecture.