Skip to content
Updated Aug 22, 2026

Wearable streams: registries

Schema deep-dive · living document · #22 in the reading sequence

Tablesstreams.metric[1], streams.episode_type[2], streams.met_activity[3], streams.metric_alias[5], streams.activity_alias[4]
Owner servicebalance (sole writer; seeded at container boot, not by request traffic)
Locatornatural key - text ids (metric.id, episode_type.id, met_activity.id, metric_alias.old_name); activity_alias keys on (profile_id, raw_text)
Last updated2026-08-21
Companionprevious: Wearable streams: model & ingest (#21) · next: Compaction pipeline (#23)

1. Scope and usage

These five tables are the vocabulary the streams pipeline resolves against. metric is the catalogue of measurable quantities (steps, resting heart rate, sleep duration); episode_type is the taxonomy of what a bout is (a walk, a meditation, a meal) and how its samples fold into a day; met_activity is the metabolic-equivalent table that turns a movement bout into MET-minutes. They are reference data: seeded from JSON at container boot and read by the engine at compaction, rollup, and scoring time. No request path writes them.

The previous page framed the pipeline - event_raw -> episode -> rollup -> baseline / movement_score -> finding - this page is the dictionary each of those stages looks words up in. episode.episode_type is a real FK into episode_type[2]; the rollup fold is chosen by metric.metric_type[20]; the movement score multiplies minutes by met_activity.met[19].

The headline property of these tables is that the vocabulary is data, not schema. A new metric, a new episode type or a new MET activity is a row in data/registries/*.json plus a reseed at boot[10][14]; the engine resolves every one of them by text id, so nothing about adding a word to the dictionary needs a migration (§9).

The remaining two tables are the alias -> canonical indirection layer, both at 0 live rows as of 2026-08-21:

  • metric_alias - the retained rename trampoline. Migration 0013 created it and seeded two aliases (resting_hr -> resting_heart_rate, activity_steps -> steps)[6]; migration 0014 deleted both rows and renamed the metrics on the server side instead, so the whole stack speaks the Health-Connect-canonical names natively[7]. The resolver runs on every pushed record[26] and returns its input unchanged while the table is empty, which is exactly what makes the next taxonomy rename a seed rather than a data migration (§9).
  • activity_alias - the per-user MET overlay. Its read (inside resolve_met) and its writer (log_unmatched) are both present in met.py, but neither is reachable from the live caller. That is a wiring defect with a named fix, kept in its own place in §9.

The three seeded tables carry a vocabulary broader than today's data exercises: 60 episode_type rows are catalogued and 22 distinct types appear across the 3 927 live episodes, which is the catalogue covering activity families ahead of the products that log them.

2. Boundaries and relationships

A registry is not…That concern lives inJoin
the raw samplestreams.event_raw - the verbatim device push (#21); event_raw.type is a metric id but the column is text, not an FKtext id, resolved not enforced
the compacted boutstreams.episode - one row per activity/night/meal; episode.episode_type is a real FK into episode_type[2]episode.episode_type FK
the daily foldstreams.rollup / streams.movement_score - the compaction page (#23); rollup reads metric.metric_type to pick a fold[20]rollup.metric / rollup.episode_type (text, no FK)
the shape projection mapstreams.shape_contribution - a sibling registry in migration 0003[1], seeded by the same script but out of scope heresource_metric / source_episode_type (text)
the per-user learning overlaystreams.activity_alias (in scope, 0 rows) - a user-taught MET mapping the resolver prefers over the shared registry when it is present[16](profile_id, raw_text)
the member-facing chartthe member app activity/timeline surface reads rollup joined to episode_type for pill labels[21], never the registry directlyvia rollup

The registry is the join hub, but metric has zero inbound foreign keys.episode_type earns three real FKs (episode, habit, met_activity - live \d); met_activity earns one (activity_alias). metric earns none: every consumer - event_raw.type, rollup.metric, the observation objects in episode.observations, shape_contribution.source_metric - joins it by matching the text id string, unenforced. Referential integrity for the most-read vocabulary in the service is a convention held up by the seeder and the parser catalogue, not a constraint (see §4).

3. Structure

DDL: metric + episode_type[1][2] · is_pillar_primary added 0011[9] · met_activity + activity_alias[3][4] · metric_alias[5]. These are raw SQL in alembic migrations - there is no ORM; models.py holds Pydantic API shapes, not these tables.

metric (46 rows)

FieldTypeReqNotes
idtextPK - the canonical metric name, e.g. steps, sleep_duration
domaintextmovement / sleep / social / nutrition / mind / body (the DDL comment lists five; body was added later by 0015)
metric_typetextcontinuous (26) / count (12) / ordinal (8) - drives the rollup fold; no categorical row exists live
unittextDisplay unit (steps, bpm, minutes, 1-5)
range_min / range_maxdouble precisionOptional plausibility bounds (mostly null)
higher_istextbetter / worse / null - direction for scoring
descriptiontextFree text

episode_type (60 rows)

FieldTypeReqNotes
idtextPK - the bout name (walking, meal, meditation)
domaintextThe pillar the type belongs to
cardinalitytextsingle (26) / session (34); DDL also lists multi, unused live
day_assignmenttextstart_date (55) / sleep_night (5); DDL also lists end_date, unused live
required_metrics / optional_metrics / qualifier_metricstext[]Which metrics a bout of this type carries; default '{}'
rollup_ruletextreduce (26) / session (34); DDL also lists passthrough, unused live (§9)
additive_metricstext[]Metrics that sum across sessions on a day rather than average
is_pillar_primarybooleanAdded 0011; the canonical probe type per pillar. Partial-unique per domain (§4)
descriptiontextFree text

met_activity (12 rows)

FieldTypeReqNotes
idtextPK - the activity key (walking, running, hiit)
episode_typetextNullable FK into episode_type - the movement type this MET value attaches to
metnumericThe metabolic-equivalent multiplier (walking 3.5, running 10)
met_lo / met_hinumericClamp band used when an RPE adjustment is applied
compendium_codetextCompendium of Physical Activities code (provenance)
compendium_versiontextDEFAULT '2011'; all 12 live rows are 2011
categorytextconditioning (4), sport (3), plus one each of ambulation/cycling/dance/running/water
aliasestext[]Free-text synonyms the resolver matches on (walk, hike, jog); default '{}'

metric_alias (0 rows)

FieldTypeReqNotes
old_nametextPK - a legacy metric name to rewrite
new_nametextThe canonical name it maps to

activity_alias (0 rows)

FieldTypeReqNotes
profile_iduuidPK part; FK to streams.profile ON DELETE CASCADE
raw_texttextPK part - the free-text activity phrase the user typed
met_activity_idtextNullable FK to met_activity - the taught mapping, or null when only a MET was learned
metnumericThe MET value to use for this phrase for this user
sourcetextHow the row was created (unmatched for the learning log)
promotedbooleanDEFAULT false - whether it has graduated into the shared registry
created_attimestamptzDEFAULT now()

Field-by-field: what and why

metric.metric_type is the fold selector. When rollup folds a day's observations of one metric, _mt reads metric_type and maps it to a fold: continuous -> mean, count -> sum, ordinal -> mean, categorical -> mode; an unknown metric defaults to continuous / mean[20]. So a steps day sums, a sleep_duration day means, and the choice lives in the registry, not the fold code. contribution.py reads the same column to decide a metric's contribution kind[24].

episode_type.is_pillar_primary replaced a hardcoded table. Exactly one type per pillar is the "primary" the onboarding bot probes when the user gives only a qualitative answer ("I sleep badly" -> ask for sleep_duration). Migration 0011 notes this used to be a hardcoded map in the parser prompt; moving it to a DB flag makes switching a pillar's canonical type a data change, not a code change[9]. Live, five domains carry a primary (meditation, walking, meal, sleep_duration, one_to_one_time); body has none.

met_activity.aliases and the resolution ladder. The MET table carries both an id and a text[] of synonyms so free text ("brisk walk", "jog") can resolve to a MET value. The resolve_met ladder is six steps - exact id / registry alias, per-user activity_alias, fuzzy match, episode_type lookup, category mean, flagged default[15] - but see §5: the only production caller enters at step 4, so the alias and fuzzy steps do not run on the live path (§9).

met_activity.episode_type is a nullable, non-unique FK. Several MET rows point at the same episode_type (fitness_classes, hiit, yoga all attach to fitness_classes), which is why the step-4 lookup has an ORDER BY (id = episode_type) DESC tie-break to prefer the row whose id matches the type[15].

metric_alias is a one-way rename map; activity_alias is a per-user overlay plus learning log. They are not the same shape and not the same lifecycle. metric_alias is (old_name -> new_name), global, applied at ingest before a record is stored[26]. activity_alias is (profile_id, raw_text) -> met_activity_id / met, per-member, consulted during scoring and (by design) appended to whenever a phrase fails to resolve[17]. Both are empty; §6 walks what each would do.

4. Invariants

InvariantEnforced by
metric.id / episode_type.id / met_activity.id uniqueDB PK[1][2][3]
At most one pillar-primary per domainDB partial UNIQUE index episode_type_one_primary_per_domain[9] (live: 5 domains have one, body has none)
Every episode / habit / met_activity type references a real episode_typeDB FK (episode_episode_type_fkey, habit_episode_type_fkey, met_activity_episode_type_fkey - live \d)
activity_alias points at a real profile + real MET rowDB FKs[4]
metric_alias.old_name uniqueDB PK[5]
Registry rows track the seed JSONApplication: seed_registries upserts every row on boot[10][14]. It only upserts, never deletes - so ids dropped from the JSON linger (§9, the 9 legacy types)
Every metric id used by a rollup / episode exists in streams.metricNothing - metric has no inbound FK; rollup, episode, observations and event_raw.type all join by text id. The parser catalogue[22] constrains the LLM, but nothing constrains a direct writer
met_activity provenance is Compendium 2011DB DEFAULT '2011'[3] + convention (all 12 live rows are 2011); no CHECK pins it
everyday_movement never earns MET-minutesApplication: NO_MET_TYPES guard returns null before resolution[18]
Rows captured to CDCDebezium publication dbz_balance (all five tables, live \d)

5. Lifecycle

A registry row has no per-row status machine - it exists from the moment the seeder writes it. The two lifecycles worth grounding are how a row comes to exist and how it is resolved at read time.

Seed lifecycle. The container entrypoint runs alembic upgrade head then scripts/seed_registries.py[14]. The seeder loads data/registries/{metric,episode_type,met_activity,shape_contribution}.json and upserts each row with ON CONFLICT (id) DO UPDATE[10][11][13]. shape_contribution (no natural key) is wiped and reinserted; the other three are upsert-only. metric_alias and activity_alias are not seeded by this script

  • their only ever-writes were the 0013 migration seed (since deleted) and the log_unmatched learning log (no callers).

MET resolution ladder (resolve_met) - the read lifecycle for a movement bout:

Every branch is grounded in met.py: the NO_MET_TYPES guard[18], the raw-text steps 1-3[15], the step-4 episode_type lookup[15], and the step-5/6 fallbacks[15]. On the live path the ladder enters at step 4 - the sole caller, compute_met_rollup, calls resolve_met(cur, profile_id, et) with no raw_text[19], so steps 1-3 (the activity_alias overlay and the fuzzy matcher) never run.

6. Populated examples: real registry rows, walked

The three seeded tables are live; the two alias tables are empty and walked from the code, clearly labelled.

metric -> the rollup fold (live rows)

id            | domain   | metric_type | unit    | higher_is
steps         | movement | count       | steps   | better
sleep_duration| sleep    | continuous  | minutes | better
mood_score    | mind     | ordinal     | 1-5     | better
KeyRead byWhat actually happens
steps.metric_type = countrollup._mt[20]count -> sum: a day's step observations are summed, not averaged
sleep_duration.metric_type = continuousrollup._mtcontinuous -> mean: a night's minutes are averaged if more than one observation lands
steps.higher_is = betterscoring / detectorsdirection hint - up is good
any metric id not in this tablerollup._mt fallbackdefaults to continuous / mean; nothing rejects an unknown metric (§4)

met_activity -> a MET-minutes rollup (live rows)

id            | episode_type   | met | met_lo | met_hi | category     | aliases
walking       | walking        | 3.5 | 2.0    | 4.8    | ambulation   | {walk,hiking,hike,stroll,"brisk walk"}
fitness_classes| fitness_classes| 6.5 | 3.0    | 8.5    | conditioning | {class,circuit,bootcamp,crossfit}
hiit          | fitness_classes| 8.5 | 6.0    | 12.0   | conditioning | {"interval training",tabata}
running       | running        | 10  | 6.0    | 14.5   | running      | {run,jog,jogging,sprint}

Walk a movement day with a walking episode carrying 30 active_minutes:

StepCodeResult
compute_met_rollup sums active_minutes per movement episodemovement.py[19]walking -> 30 minutes
calls resolve_met(cur, profile, 'walking') - no raw_text, enters at step 4met.py[15]SELECT … WHERE episode_type='walking' -> the walking row, met = 3.5
minutes × METmovement.py[19]3.5 × 30 = 105 MET-minutes, written to rollup as met_minutes
a fitness_classes episode insteadmet.py[15]three rows attach to fitness_classes; the ORDER BY (id = episode_type) DESC tie-break picks id='fitness_classes' (met 6.5), not hiit (8.5) or yoga (3.0)

The met_lo / met_hi band only clamps a MET when an RPE adjustment is supplied; resolve_met's rpe_adjust defaults to None and the live caller passes none, so the raw met is used[15].

metric_alias (0 rows) - the write path, walked from code

On every wearable push the ingest route resolves each record's type through _resolve_alias before storing it[26]:

SELECT new_name FROM streams.metric_alias WHERE old_name = %s;   -- returns 0 rows
-> return metric unchanged

Constructed: were a row ('resting_hr','resting_heart_rate') present (as migration 0013 seeded before 0014 deleted it), a pushed resting_hr record would be stored as resting_heart_rate. Today the table is empty, so the resolver is a per-record no-op[25] - retained only as a trampoline for a future rename[7].

activity_alias (0 rows) - walked from code

Constructed from met.py. Read (step 2 of the ladder): if a movement bout carried free text, resolve_met would consult SELECT met, met_activity_id FROM streams.activity_alias WHERE profile_id=… AND raw_text=…, and a user-taught mapping would win over the registry[16]. Write (the learning log): when a phrase fails to resolve, log_unmatched would INSERT … ON CONFLICT DO NOTHING with source='unmatched' so the library can grow[17]. Neither path executes today: the read sits behind an if raw: the live caller does not satisfy, and log_unmatched has no callers in src/. 0 rows is the consistent count, and §9 records the two call sites that close the loop.

7. Who references the registries

WhereColumn / mechanismMeaning there
streams.episodeepisode_type FK[2]every compacted bout must name a real type
streams.habitepisode_type FK (live \d)a habit is defined over a bout type
streams.met_activityepisode_type FK (nullable)[3]the movement type a MET value attaches to
streams.activity_aliasmet_activity_id FK[4]a user-taught mapping points at a registry activity
engine/rollup.pyreads episode_type.rollup_rule/additive_metrics + metric.metric_type[21]the two-stage daily fold
engine/movement.py (via met.py)reads met_activity + activity_alias[19]MET-minute scoring
parser/catalogue.pyreads metric + episode_type into the LLM prompt[22]so the parser cannot invent metric ids or types
engine/contribution.pyreads metric.metric_type[24]contribution kind for the shape projection
surfaces/*_routes.py, ingest/gate.py, habits/bridge.pySELECT domain / required_metrics FROM streams.episode_type WHERE id=… (many sites)validate a submitted type + look up its pillar
surfaces/ingest_routes.pymetric_alias via _resolve_alias[26]canonicalise a pushed record's metric (no-op while empty)

metric is read the most and FK'd the least: every reference to it is a text-id match, never a constraint.

8. Design determinations

  1. The registry is data, seeded from JSON, not migration DDL. Tables are created by migrations but their contents come from data/registries/*.json via an idempotent boot seeder[10][14]. Changing the vocabulary is a JSON edit, not a schema change.
  2. Pillar-primary lives in the DB, not the prompt. D-recorded in migration 0011: the canonical probe type per pillar was lifted out of a hardcoded parser map into is_pillar_primary, guarded by a one-per-domain partial-unique index[9].
  3. Central MET registry pinned to Compendium 2011 with provenance. D-43 (#1370) - met_activity carries compendium_code + a compendium_version defaulting to 2011[3].
  4. Per-user alias overlay + unmatched learning log. D-44 (#1371) - activity_alias was designed as a user-taught overlay the resolver prefers, with an unmatched-source learning log to grow the shared library[4]. The table and both functions exist; the two call sites that connect them are named in §9.
  5. The alias table was the wrong fix; the server taxonomy was renamed instead. Migration 0014 (#1510) records that the 0013 metric_alias "papered over the wire format" while rollup, detectors and contribution still looked for the legacy ids; the fix flipped the whole server side to the Health-Connect-canonical names and dropped the alias rows, keeping the table only as a future trampoline[7].
  6. A separate body domain for physiometry. Migration 0015 (#1511) added weight/height/body_fat under a new body domain rather than folding them into movement or sleep, with the rationale recorded in the migration: Compendium semantics, clean rollup partitioning, and zero blast-radius because episode.domain is free-text with no CHECK[8].

9. Caveats and extensibility

Group and individual. The registries are member-agnostic reference data - they describe metrics and activities, not people, so they serve scheme-based and direct-to-consumer members identically. The only per-member registry table is activity_alias, keyed by profile_id with an ON-DELETE-CASCADE FK to profile[4] - a member's taught aliases are their own and vanish with them. Group-ness never touches the vocabulary.

Where to extend. The vocabulary is reference data resolved by text id, so almost every extension here is a JSON edit and a restart rather than a schema change.

When we need …What to addWhere
a new metric or episode type (a new wearable signal, a new bout family)one object in the seed JSON - id, domain, metric_type / rollup_rule, units. The engine resolves by text id, so the fold, the parser catalogue and the rollups pick it up with no migrationservices/balance/data/registries/metric.json and episode_type.json, applied by scripts/seed_registries.py on the next boot[10][14]. Migration 0015 shows the belt-and-braces variant (an idempotent migration INSERT) when a rollout needs the row before the next seed[8]
new activities to earn MET-minutes (rowing, climbing, a class format)a met_activity row - id, episode_type, met, the met_lo/met_hi band, compendium_code, and free-text aliases. compute_met_rollup multiplies minutes by that met directly, so scoring changes with the rowdata/registries/met_activity.json via upsert_met_activity[13]; read at resolve_met step 4[15] and applied in movement.py[19]
a taxonomy rename (a vendor renames a metric, or we canonicalise one)seed metric_alias with (old_name -> new_name); _resolve_alias already runs on every pushed record and rewrites at ingest, so old clients keep working through the changestreams.metric_alias, read per record in ingest_routes.py[26]. This is the trampoline 0014 retained on purpose[7]
to move a pillar's canonical probe typeflip is_pillar_primary on the new type; the one-per-domain partial unique index keeps it honest, and the parser prompt follows the DBepisode_type.is_pillar_primary[9]
per-member MET learning (a member teaches "my spin class")the two call sites named under Known defect below - pass the bout's free text into resolve_met, and call log_unmatched on a missengine/movement.py L44[19]; the read and writer are already written in engine/met.py[16][17]

Known defect: activity_alias is unreachable in both directions. Not an empty table awaiting a product - a wiring gap with a two-line fix:

  • What. The read (step 2 of the MET ladder) sits behind an if raw: guard, and the sole caller compute_met_rollup calls resolve_met(cur, profile_id, et) with no raw_text[19], so steps 1-3 of the six-step ladder do not execute. The writer log_unmatched has zero callers in src/[17].
  • Fix, and where. Either wire it - pass the episode's free text through compute_met_rollup into resolve_met, and call log_unmatched when resolution falls through to the category mean - or remove log_unmatched and the if raw: branch and let the table go with them. Both edits are in services/balance/src/balance/engine/movement.py and engine/met.py; D-44 (#1371) is the record that specified the behaviour.

Stated plainly (live facts about the seeded tables):

  • The seeder upserts and does not delete, so the taxonomy accumulates.episode_type has 60 live rows against 51 in the seed JSON; the extra 9 (workout, biking, pilates, yoga, step_day, sleep_night, mood_checkin, mindfulness_practice, social_occasion) are legacy ids retained for FK back-compat with existing episode rows and filtered out of the parser catalogue by LEGACY_EPISODE_TYPES[23]. Pruning one means a delete in the seeder plus a check that no episode row still points at it.
  • metric_alias is wired and empty. _resolve_alias runs on every pushed record and returns its input unchanged, because 0014 emptied the table[7]. The per-record lookup is what keeps the next rename a seed (§8 det 5).
  • Broad vocabulary, narrower current usage. 60 episode types catalogued and 22 distinct in use across 3 927 live episodes; 46 metrics, of which the movement score reads active_minutes plus the MET table.
  • DDL comments outrun the live data. metric.domain lists five domains and six exist (body); episode_type.cardinality lists multi (0 live), day_assignment lists end_date (0 live), rollup_rule lists passthrough (0 live); no categorical metric row exists though rollup._FOLD handles one[21]. The enums are the headroom; the seed is what is in use.
  • metric has no inbound FK. The most-read vocabulary in the service is joined by text id throughout, which is what lets a metric appear before the registry catches up - and equally means a rollup.metric or event_raw.type with no matching metric row is tolerated silently (§4).
  • met_activity.compendium_version is a DEFAULT, not a CHECK. All 12 rows are 2011 because the seed says so; a second compendium edition can coexist in the column without a migration.

References

Code links are pinned to commit b61c5802 on main (2026-08-21); the file is the anchor if lines drift. Pins are checked mechanically by docs/site/scripts/check-code-refs.py.

  1. balance/migrations/versions/0003_registries.py#L17 - CREATE TABLE streams.metric (+ shape_contribution sibling below it)
  2. 0003_registries.py#L30 - CREATE TABLE streams.episode_type
  3. 0012_movement_met.py#L20 - CREATE TABLE streams.met_activity (Compendium 2011 default)
  4. 0012_movement_met.py#L31 - CREATE TABLE streams.activity_alias (D-44 per-user overlay)
  5. 0013_wearable_push.py#L69 - CREATE TABLE streams.metric_alias
  6. 0013_wearable_push.py#L74 - the two seeded alias rows (later deleted)
  7. 0014_metric_rename.py#L40 - server-side rename + DELETE FROM streams.metric_alias (#1510)
  8. 0015_new_metrics_and_body_metrics.py#L20 - new HC metrics + body domain rationale (#1511)
  9. 0011_pillar_primary.py#L30 - is_pillar_primary column + one-primary-per-domain partial UNIQUE index
  10. scripts/seed_registries.py#L116 - main: load JSON, upsert metrics/types/mets, reseed contributions
  11. seed_registries.py#L17 - upsert_metric (ON CONFLICT DO UPDATE)
  12. seed_registries.py#L36 - upsert_episode_type
  13. seed_registries.py#L69 - upsert_met_activity
  14. scripts/entrypoint.sh#L4 - boot order: alembic upgrade, then seed_registries
  15. engine/met.py#L39 - resolve_met six-step ladder (raw-text steps, episode_type step-4, category/default fallbacks)
  16. engine/met.py#L52 - step 2: per-user activity_alias read (unreachable on live path)
  17. engine/met.py#L86 - log_unmatched: the learning-log writer (no callers)
  18. engine/met.py#L11 - NO_MET_TYPES steps guard
  19. engine/movement.py#L29 - compute_met_rollup; resolve_met(cur, profile_id, et) at L44 (no raw_text)
  20. engine/rollup.py#L19 - _mt reads metric.metric_type -> fold selector
  21. engine/rollup.py#L25 - rollup_profile_day: two-stage fold, _FOLD map (includes categorical -> mode, no live row yet)
  22. parser/catalogue.py#L28 - render_catalogue injects metric + episode_type ids into the parser prompt
  23. parser/catalogue.py#L15 - LEGACY_EPISODE_TYPES: legacy ids excluded from the parser catalogue
  24. engine/contribution.py#L45 - reads metric.metric_type for the shape projection
  25. surfaces/ingest_routes.py#L297 - _resolve_alias: metric_alias lookup, returns input on miss
  26. surfaces/ingest_routes.py#L344 - _resolve_alias called per pushed record, with the 0013/0014 comment

Live-schema facts (row counts, per-domain census, the pillar-primary set, the metric-has-no-inbound-FK check, enum distributions, and the seed-JSON-vs-live episode_type diff) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d balance · \d streams.metric, \d streams.episode_type, \d streams.met_activity, \d streams.metric_alias, \d streams.activity_alias, plus pg_constraint and row-census queries, 2026-08-21.

Olly Health Insurance Platform