Wearable streams: registries
Schema deep-dive · living document · #22 in the reading sequence
| Tables | streams.metric[1], streams.episode_type[2], streams.met_activity[3], streams.metric_alias[5], streams.activity_alias[4] |
| Owner service | balance (sole writer; seeded at container boot, not by request traffic) |
| Locator | natural key - text ids (metric.id, episode_type.id, met_activity.id, metric_alias.old_name); activity_alias keys on (profile_id, raw_text) |
| Last updated | 2026-08-21 |
| Companion | previous: 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 (insideresolve_met) and its writer (log_unmatched) are both present inmet.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 in | Join |
|---|---|---|
| the raw sample | streams.event_raw - the verbatim device push (#21); event_raw.type is a metric id but the column is text, not an FK | text id, resolved not enforced |
| the compacted bout | streams.episode - one row per activity/night/meal; episode.episode_type is a real FK into episode_type[2] | episode.episode_type FK |
| the daily fold | streams.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 map | streams.shape_contribution - a sibling registry in migration 0003[1], seeded by the same script but out of scope here | source_metric / source_episode_type (text) |
| the per-user learning overlay | streams.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 chart | the member app activity/timeline surface reads rollup joined to episode_type for pill labels[21], never the registry directly | via 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)
| Field | Type | Req | Notes |
|---|---|---|---|
id | text | ✓ | PK - the canonical metric name, e.g. steps, sleep_duration |
domain | text | ✓ | movement / sleep / social / nutrition / mind / body (the DDL comment lists five; body was added later by 0015) |
metric_type | text | ✓ | continuous (26) / count (12) / ordinal (8) - drives the rollup fold; no categorical row exists live |
unit | text | Display unit (steps, bpm, minutes, 1-5) | |
range_min / range_max | double precision | Optional plausibility bounds (mostly null) | |
higher_is | text | better / worse / null - direction for scoring | |
description | text | Free text |
episode_type (60 rows)
| Field | Type | Req | Notes |
|---|---|---|---|
id | text | ✓ | PK - the bout name (walking, meal, meditation) |
domain | text | ✓ | The pillar the type belongs to |
cardinality | text | ✓ | single (26) / session (34); DDL also lists multi, unused live |
day_assignment | text | ✓ | start_date (55) / sleep_night (5); DDL also lists end_date, unused live |
required_metrics / optional_metrics / qualifier_metrics | text[] | ✓ | Which metrics a bout of this type carries; default '{}' |
rollup_rule | text | ✓ | reduce (26) / session (34); DDL also lists passthrough, unused live (§9) |
additive_metrics | text[] | ✓ | Metrics that sum across sessions on a day rather than average |
is_pillar_primary | boolean | ✓ | Added 0011; the canonical probe type per pillar. Partial-unique per domain (§4) |
description | text | Free text |
met_activity (12 rows)
| Field | Type | Req | Notes |
|---|---|---|---|
id | text | ✓ | PK - the activity key (walking, running, hiit) |
episode_type | text | Nullable FK into episode_type - the movement type this MET value attaches to | |
met | numeric | ✓ | The metabolic-equivalent multiplier (walking 3.5, running 10) |
met_lo / met_hi | numeric | Clamp band used when an RPE adjustment is applied | |
compendium_code | text | Compendium of Physical Activities code (provenance) | |
compendium_version | text | ✓ | DEFAULT '2011'; all 12 live rows are 2011 |
category | text | ✓ | conditioning (4), sport (3), plus one each of ambulation/cycling/dance/running/water |
aliases | text[] | ✓ | Free-text synonyms the resolver matches on (walk, hike, jog); default '{}' |
metric_alias (0 rows)
| Field | Type | Req | Notes |
|---|---|---|---|
old_name | text | ✓ | PK - a legacy metric name to rewrite |
new_name | text | ✓ | The canonical name it maps to |
activity_alias (0 rows)
| Field | Type | Req | Notes |
|---|---|---|---|
profile_id | uuid | ✓ | PK part; FK to streams.profile ON DELETE CASCADE |
raw_text | text | ✓ | PK part - the free-text activity phrase the user typed |
met_activity_id | text | Nullable FK to met_activity - the taught mapping, or null when only a MET was learned | |
met | numeric | The MET value to use for this phrase for this user | |
source | text | ✓ | How the row was created (unmatched for the learning log) |
promoted | boolean | ✓ | DEFAULT false - whether it has graduated into the shared registry |
created_at | timestamptz | ✓ | DEFAULT 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
| Invariant | Enforced by |
|---|---|
metric.id / episode_type.id / met_activity.id unique | DB PK[1][2][3] |
| At most one pillar-primary per domain | DB 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_type | DB 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 row | DB FKs[4] |
metric_alias.old_name unique | DB PK[5] |
| Registry rows track the seed JSON | Application: 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.metric | Nothing - 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 2011 | DB DEFAULT '2011'[3] + convention (all 12 live rows are 2011); no CHECK pins it |
everyday_movement never earns MET-minutes | Application: NO_MET_TYPES guard returns null before resolution[18] |
| Rows captured to CDC | Debezium 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_unmatchedlearning 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| Key | Read by | What actually happens |
|---|---|---|
steps.metric_type = count | rollup._mt[20] | count -> sum: a day's step observations are summed, not averaged |
sleep_duration.metric_type = continuous | rollup._mt | continuous -> mean: a night's minutes are averaged if more than one observation lands |
steps.higher_is = better | scoring / detectors | direction hint - up is good |
| any metric id not in this table | rollup._mt fallback | defaults 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:
| Step | Code | Result |
|---|---|---|
compute_met_rollup sums active_minutes per movement episode | movement.py[19] | walking -> 30 minutes |
calls resolve_met(cur, profile, 'walking') - no raw_text, enters at step 4 | met.py[15] | SELECT … WHERE episode_type='walking' -> the walking row, met = 3.5 |
| minutes × MET | movement.py[19] | 3.5 × 30 = 105 MET-minutes, written to rollup as met_minutes |
a fitness_classes episode instead | met.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 unchangedConstructed: 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
| Where | Column / mechanism | Meaning there |
|---|---|---|
streams.episode | episode_type FK[2] | every compacted bout must name a real type |
streams.habit | episode_type FK (live \d) | a habit is defined over a bout type |
streams.met_activity | episode_type FK (nullable)[3] | the movement type a MET value attaches to |
streams.activity_alias | met_activity_id FK[4] | a user-taught mapping points at a registry activity |
engine/rollup.py | reads 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.py | reads metric + episode_type into the LLM prompt[22] | so the parser cannot invent metric ids or types |
engine/contribution.py | reads metric.metric_type[24] | contribution kind for the shape projection |
surfaces/*_routes.py, ingest/gate.py, habits/bridge.py | SELECT domain / required_metrics FROM streams.episode_type WHERE id=… (many sites) | validate a submitted type + look up its pillar |
surfaces/ingest_routes.py | metric_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
- The registry is data, seeded from JSON, not migration DDL. Tables are created by migrations but their contents come from
data/registries/*.jsonvia an idempotent boot seeder[10][14]. Changing the vocabulary is a JSON edit, not a schema change. - 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]. - Central MET registry pinned to Compendium 2011 with provenance. D-43 (#1370) -
met_activitycarriescompendium_code+ acompendium_versiondefaulting to2011[3]. - Per-user alias overlay + unmatched learning log. D-44 (#1371) -
activity_aliaswas designed as a user-taught overlay the resolver prefers, with anunmatched-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. - 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]. - A separate
bodydomain for physiometry. Migration 0015 (#1511) added weight/height/body_fat under a newbodydomain rather than folding them into movement or sleep, with the rationale recorded in the migration: Compendium semantics, clean rollup partitioning, and zero blast-radius becauseepisode.domainis 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 add | Where |
|---|---|---|
| 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 migration | services/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 row | data/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 change | streams.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 type | flip is_pillar_primary on the new type; the one-per-domain partial unique index keeps it honest, and the parser prompt follows the DB | episode_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 miss | engine/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 callercompute_met_rollupcallsresolve_met(cur, profile_id, et)with noraw_text[19], so steps 1-3 of the six-step ladder do not execute. The writerlog_unmatchedhas zero callers insrc/[17]. - Fix, and where. Either wire it - pass the episode's free text through
compute_met_rollupintoresolve_met, and calllog_unmatchedwhen resolution falls through to the category mean - or removelog_unmatchedand theif raw:branch and let the table go with them. Both edits are inservices/balance/src/balance/engine/movement.pyandengine/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_typehas 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 existingepisoderows and filtered out of the parser catalogue byLEGACY_EPISODE_TYPES[23]. Pruning one means a delete in the seeder plus a check that noepisoderow still points at it. metric_aliasis wired and empty._resolve_aliasruns 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_minutesplus the MET table. - DDL comments outrun the live data.
metric.domainlists five domains and six exist (body);episode_type.cardinalitylistsmulti(0 live),day_assignmentlistsend_date(0 live),rollup_rulelistspassthrough(0 live); nocategoricalmetric row exists thoughrollup._FOLDhandles one[21]. The enums are the headroom; the seed is what is in use. metrichas 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 arollup.metricorevent_raw.typewith no matchingmetricrow is tolerated silently (§4).met_activity.compendium_versionis a DEFAULT, not a CHECK. All 12 rows are2011because 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.
balance/migrations/versions/0003_registries.py#L17-CREATE TABLE streams.metric(+shape_contributionsibling below it)0003_registries.py#L30-CREATE TABLE streams.episode_type0012_movement_met.py#L20-CREATE TABLE streams.met_activity(Compendium 2011 default)0012_movement_met.py#L31-CREATE TABLE streams.activity_alias(D-44 per-user overlay)0013_wearable_push.py#L69-CREATE TABLE streams.metric_alias0013_wearable_push.py#L74- the two seeded alias rows (later deleted)0014_metric_rename.py#L40- server-side rename +DELETE FROM streams.metric_alias(#1510)0015_new_metrics_and_body_metrics.py#L20- new HC metrics +bodydomain rationale (#1511)0011_pillar_primary.py#L30-is_pillar_primarycolumn + one-primary-per-domain partial UNIQUE indexscripts/seed_registries.py#L116-main: load JSON, upsert metrics/types/mets, reseed contributionsseed_registries.py#L17-upsert_metric(ON CONFLICT DO UPDATE)seed_registries.py#L36-upsert_episode_typeseed_registries.py#L69-upsert_met_activityscripts/entrypoint.sh#L4- boot order: alembic upgrade, then seed_registriesengine/met.py#L39-resolve_metsix-step ladder (raw-text steps, episode_type step-4, category/default fallbacks)engine/met.py#L52- step 2: per-useractivity_aliasread (unreachable on live path)engine/met.py#L86-log_unmatched: the learning-log writer (no callers)engine/met.py#L11-NO_MET_TYPESsteps guardengine/movement.py#L29-compute_met_rollup;resolve_met(cur, profile_id, et)at L44 (no raw_text)engine/rollup.py#L19-_mtreadsmetric.metric_type-> fold selectorengine/rollup.py#L25-rollup_profile_day: two-stage fold,_FOLDmap (includescategorical -> mode, no live row yet)parser/catalogue.py#L28-render_catalogueinjects metric + episode_type ids into the parser promptparser/catalogue.py#L15-LEGACY_EPISODE_TYPES: legacy ids excluded from the parser catalogueengine/contribution.py#L45- readsmetric.metric_typefor the shape projectionsurfaces/ingest_routes.py#L297-_resolve_alias:metric_aliaslookup, returns input on misssurfaces/ingest_routes.py#L344-_resolve_aliascalled 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.
