Skip to content
Updated Aug 22, 2026

Wearable streams: findings & profile

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

Tablesstreams.finding[1], streams.finding_evidence[2], streams.insight[3][4], streams.knowledge_gap[5], streams.profile[6], streams.onboarding_state[7], streams.self_assessment[8]
Owner servicebalance (sole writer)
Locatornatural key - the chain tables key on profile_id (uuid, FK to streams.profile); the profile itself keys on party_locator (a PTY- alias or a raw persona uuid)
Last updated2026-08-21
Companionprevious: Scores (#24) · reading sequence · this page closes the Phase-3 streams section

1. Scope and usage

This page closes the Phase-3 streams section by joining its two ends: the analysis layer the engine emits, and the member state it runs against. Everything upstream - event_raw samples compacted into episodes, rolled up into rollup/baseline, scored into movement_score/shape_score

  • exists so that a nightly engine pass can say something true about a member. The four finding-chain tables are where it says it:

  • finding - one detected pattern (a rising trend, a z-score anomaly, a sustained low run). The machine fact: which detector fired, on which metric, with what statistic and confidence.[1]

  • finding_evidence - the episodes that back a finding. 39,512 rows for 539 findings (the second-biggest table in the domain, after event_raw): every finding fans out to every episode that carried its metric across the detector's window.[2]

  • insight - the member-facing object. History lives in finding; the insight table holds the one current object per (profile, detector, metric), discretized into status/magnitude bands and stamped with the coverage gate's verdict, its statement filled later by the narration LLM.[4]

  • knowledge_gap - a shape the system cannot yet speak to because its data is still below floor. What is missing, tracked as an open/resolved record.[5]

The three member-state tables are what the engine runs for:

  • profile - the streams identity, one per member, keyed by party_locator. Every other streams table cascades off it. 35 live rows.[6]
  • onboarding_state - the server-tracked onboarding walk: a step machine, a per-step step_data jsonb, a completion stamp. 1 live row; the member app runs onboarding through its own flow today, and this table is the balance-side walk that serves it when onboarding routes here (§9).[7]
  • self_assessment - a member's subjective rating of a whole pillar ("I think I'm moderately active"), with the transcript snippet it came from. 49 live rows, captured by the chat/onboarding ingest and held for the analysis reader that folds self-report against the computed picture (§9).[8]

Two properties shape how the rest of the page reads. The analysis chain is detector-shaped and open at the top: a detector is a pure function returning a shared DetectorFinding, and the finding row absorbs whatever it returns - detector and method are free text, evidence_window is jsonb - so a new primitive lands with no migration (§9). The member-state tables are modelled ahead of the surfaces that consume them: the onboarding step machine and the self-report ratings are in place and populated, ready for the client and the reader that use them. One genuine defect runs through the chain and is stated in §9: finding carries no unique key, so each recompute re-inserts and the 539 live rows collapse to 144 distinct (profile, detector, metric, value, window) signatures across 11 profiles.

2. Boundaries and relationships

A finding is not…That concern lives inJoin
the raw sampleevent_raw (page #22) - the finding never cites a sample, only the episodes compacted from themfinding_evidence.episode_idepisode
the rollup / baseline the detector readsrollup / baseline (page #23) - the finding is the detector's output over that series, not the seriesnone stored; recomputed each pass[10]
the scoremovement_score / shape_score (page #24) - a score is the continuous number; a finding is a discrete detected patternboth hang off profile_id; no direct FK
the member-facing sentenceinsight.statement, filled by the narration LLM (#1505) - the finding is the machine fact the LLM translates, never interpretsinsight.finding_idfinding
a clinical actiontriage / care - balance only observes (insight.posture = 'observation'); it routes nothing and diagnoses nothingnone; triage calls balance's POST /streams/digest for context
the member's identityparties (party service) - profile.party_locator is a soft, un-FK'd PTY- referencetext locator, no cross-DB FK

Everything cascades off profile. Nineteen streams tables carry a profile_id FK with ON DELETE CASCADE back to streams.profile[6], so a profile delete tears down the member's whole streams footprint in one row drop. The chain is deeper still: insight.finding_id and finding_evidence.finding_id both cascade off finding, so deleting a finding takes its insight and evidence with it (§5).

3. Structure

DDL: derived tables[1][2][3][5], insight object columns[4], profile[6], onboarding_state[7], self_assessment[8]. Tables are raw SQL inside alembic migrations - there is no ORM; the Pydantic models under balance/streams/models.py are API shapes, not table definitions (P3 house rule).

streams.finding

FieldTypeReqNotes
iduuidPK, gen_random_uuid()
profile_iduuidFK → profile, ON DELETE CASCADE
detectortexttrend / anomaly / period / correlation / activity_level / hrv_balance / rhr_trend / sleep_regularity / sleep_debt - which primitive fired
metrictextThe metric analysed (active_minutes, steps, hrv, sleep_duration…); nullable in DB, always set in practice
shapetextThe pillar the metric belongs to (movement / sleep / recovery); indexed with profile_id
methodtextThe statistic used - ols_slope_ttest, z_score, descriptive_run, spearman, rmssd_ratio_28d, week_mean_ratio_28d
valuedouble precisionThe statistic's value (slope, z, run-length, ratio); nullable
evidence_windowjsonb{n, p, r2} / {baseline_mean, baseline_n, value} etc. - the numbers behind the finding
confidencedouble precisionDefault 1.0; 1 - pvalue for stats detectors, 1.0 for descriptive
computed_attimestamptzDefault now(); the pass that produced this row

streams.finding_evidence

FieldTypeReqNotes
finding_iduuidFK → finding, ON DELETE CASCADE; half of the PK
episode_iduuidFK → episode, ON DELETE CASCADE; other half of the PK, and separately indexed

Two columns, no payload - a pure join table. The composite PK (finding_id, episode_id) makes a finding→episode link idempotent, and the standalone episode_id index is what the delete cascade walks backwards (§5).

streams.insight

FieldTypeReqNotes
iduuidPK
profile_iduuidFK → profile
finding_iduuidFK → finding; NULL for affirmative-state and placeholder objects (no signal behind them)
posturetextDefault 'observation' - the only posture; balance observes, never advises
statementtextThe member-facing sentence; '' until the W3 narrator fills it
detector / metrictextThe upsert key with profile_id; NULL only on a pre-0017 legacy row
statustextDiscretized band (rising, below_baseline, on_baseline, sustained_low…)
magnitudetextmoderate / marked for signals; steady for affirmative states
coverage_okbooleanDefault true; the coverage gate's verdict, stamped at compute time
gate_outcometextDefault 'pass'; pass / insufficient / stale / source_missing
formula_versiontexte.g. hrv_balance@1.0 - auditability + backfill
sourcesjsonbDefault []; distinct episode attributions behind the evidence
window_days / n_observationsintegerThe design-doc §2.2 contract fields
valuedouble precisionThe finding's raw statistic, carried for the card
computed_attimestamptzDefault now()

streams.knowledge_gap

FieldTypeReqNotes
iduuidPK
profile_iduuidFK → profile
shapetextThe pillar that is under-covered (movement, sleep…)
gap_typetextThe DDL comment lists insufficient_data / deficit; v1 writes insufficient_data, deficit is the reserved second type (§9)
evidencejsonb{reason, window_days} - e.g. {"reason":"below_floor","window_days":30}
computed_attimestamptzDefault now()
resolved_attimestamptzSet when the shape leaves calibrating; NULL = still open

streams.profile

FieldTypeReqNotes
iduuidPK; the profile_id every other streams table joins on
party_locatortextUNIQUE; the member's identity (a PTY- alias or a raw persona uuid); separately indexed
display_nametextOptional friendly name (persona fixtures carry one)
created_attimestamptzDefault now()
deleted_attimestamptzSoft-delete tombstone; hard purge sweeps 30 days later

streams.onboarding_state

FieldTypeReqNotes
profile_iduuidPK and FK - exactly one state row per profile
current_steptextDefault 'splash'; a value from the linear STEPS tuple (splash→…→pillars.movement→…→complete)
started_attimestamptzDefault now()
completed_attimestamptzSet when the walk reaches the terminal step; NULL = in progress
step_datajsonbDefault {}; accretes {step: payload} as each step completes

streams.self_assessment

FieldTypeReqNotes
iduuidPK
profile_iduuidFK → profile
pillartextCHECK in ('movement','sleep','joy','eating','mind') - the only DB CHECK in this page's tables
rating_texttextThe subjective rating ("moderately active", "feeling OK lately")
source_texttextThe original transcript snippet the rating came from (free-text; PII)
created_attimestamptzDefault now(); indexed with (profile_id, pillar)

Field-by-field: what and why

finding.evidence_window is the finding, not a footnote. The row's detector/method/value say that a pattern was found; evidence_window carries the numbers that make it auditable - for a trend, {n, p, r2}; for an anomaly, {baseline_mean, baseline_stddev, baseline_n, value}; for a period run, {threshold, min_run, longest_run, n}.[13][14][15] The in-memory shape is DetectorFinding, a frozen dataclass every detector returns; persist_findings copies its fields straight onto the row.[12]

The finding→evidence fan-out. persist_findings inserts the finding, then loops the same episode_ids list into finding_evidence for every finding in the batch.[9] Those episode ids are collected in recompute as every non-deleted episode carrying the finding's metric across the detector's window.[10] So a finding's evidence count is window_length × episode_density: a 7-day movement trend on an active persona links ~8 episodes; a 60-day RHR window links dozens. Live, the 539 findings carry 73.3 evidence rows on average, min 1, max 610 - which is how 539 findings become 39,512 evidence rows. Note the distinction the numbers reveal: the finding's statistic runs on daily rollups (a trend's evidence_window.n is a count of days), while its evidence links the episodes underneath (more numerous, because a day can hold several) - §6 walks a concrete case.

insight is a current-state upsert; finding is append-only history. The 0017 migration turned the insight row into a self-describing object and added the partial unique index insight_current_object_idx (profile_id, detector, metric) WHERE detector IS NOT NULL.[4] Every emitter writes INSERT … ON CONFLICT (profile_id, detector, metric) WHERE detector IS NOT NULL DO UPDATE, so a member holds at most one live object per detector-metric - the status flips (e.g. below_baselineon_baseline) as their data changes.[20] That is why 539 findings project down to 60 insight rows live.

status/magnitude are decided deterministically, before any LLM sees the row. discretize maps a finding to a (status, magnitude) band by fixed thresholds - hrv_balance below 1.0 is below_baseline, an anomaly with |z| ≥ 3.0 is marked.[18] The narration LLM later fills statement from an already-decided fact; it never interprets value. This is the single guardrail against numeric hallucination, and it is why statement ships '' from day one and the API contract is stable before the narrator exists.

coverage_ok + gate_outcome freeze the four-condition coverage gate.gate_finding evaluates the design-doc §5.1 conditions - density (re-check the detector floor), recency (last observation inside a per-metric freshness bound), and baseline maturity (trend/z detectors need ≥ 21 baseline days) - and returns pass / insufficient / stale.[19] The verdict is stamped onto the row so consumers must not re-derive it: a coverage_ok=false object still ships, carrying its outcome, so the card renders a "need more data" / "stale" state instead of vanishing.

finding_id NULL is a feature, not a defect. Two emitters write insight objects with no finding behind them: emit_state_object writes the affirmative state ("your resting heart rate is steady") when a detector is silent but the data passed its floor[21], and emit_placeholder_object writes a gated placeholder (source_missing / insufficient) so the bundle can show why a kind has nothing to report rather than silently omitting it.[22] Both use the same (profile, detector, metric) upsert key as signal objects, so a member's current object per kind is exactly one row whether the news is good, bad, or absent. Live, 10 of 60 insights have finding_id set with gate_outcome = pass; 16 are insufficient, 13 source_missing, 9 stale - the honest majority of the surface is "we can't say yet".

knowledge_gap.gap_type carries a second type ahead of its emitter.emit_gaps reads each shape_score for the profile and, for every shape still calibrating, opens an insufficient_data gap if none is open; when the shape leaves calibration it stamps resolved_at.[23] The docstring is explicit that "v1 only emits insufficient_data"; deficit - the shape whose data is sufficient but below where it should be - is named in the DDL comment and reserved for the emitter branch that writes it (§9). All 95 live gaps are insufficient_data, 22 of them resolved.

profile is auto-provisioned on first authenticated call.find_or_create_profile does an INSERT … ON CONFLICT (party_locator) DO UPDATE … RETURNING id, so the profile row appears the first time a member's JWT reaches balance and is idempotent thereafter.[25] party_locator is a soft reference to the party service - no cross-DB FK - and live it is a mix: some rows carry PTY- aliases, others raw persona uuids from the fixture seeder.

4. Invariants

InvariantEnforced by
One profile per party_locatorDB UNIQUE on profile.party_locator[6] + the ON CONFLICT upsert[25]
One current insight object per (profile, detector, metric)DB partial UNIQUE index insight_current_object_idx[4] + ON CONFLICT … DO UPDATE on every emitter[20]
One onboarding_state row per profileDB PK on profile_id[7] + INSERT … ON CONFLICT DO UPDATE in get_or_init_state[27]
A finding→episode link is uniqueDB composite PK (finding_id, episode_id)[2]
Evidence/insight cannot outlive its finding; nothing outlives its profileDB FKs ON DELETE CASCADE throughout[1][2][3]
self_assessment.pillar is one of fiveDB CHECK constraint[8]
A finding is only emitted when its detector clears its floorApplication - every detector calls has_floor_data(shape, detector, n_days) and returns None (silence) below it[13]
status/magnitude are deterministic, not LLM-decidedApplication - discretize maps value→band by fixed thresholds before narration[18]
A gated object still ships (never silently vanishes)Application - coverage_ok=false objects are inserted with their gate_outcome, not skipped[19]
A finding survives only while it has live evidenceApplication - the delete cascade prunes a finding once no non-deleted episode remains[33]
finding rows are unique per passNothing - finding has no unique key; each recompute re-inserts, so duplicates accumulate (§9)
posture is always observationConvention - the column defaults to 'observation' and no writer sets anything else; no CHECK forbids it
Row changes captured to CDCDebezium publication dbz_balance on all seven tables (live \d)

5. Lifecycle

There is no single status machine here - the seven tables have three different shapes of life. The shared spine is how a finding is produced and projected; the three member-state tables each have their own simpler arc.

How a finding is produced (the shared flow)

The pass is driven by an in-process APScheduler: a nightly cron (default 0 4 * * *, overridable by GrowthBook flag balance.engine.daily.cron) recomputes every active profile[34][35], and a debounced on-ingest hook fires a fast compact+recompute ~15 s after the last wearable push (narration skipped on that path).[36] Inside a pass, recompute_profile runs the primary-shape detectors (trend, anomaly, period) and then the W1 sleep/recovery/activity primitives; each signal finding is persisted and its insight object emitted via _persist_and_emit, while a silent detector with floor-passing data emits the affirmative state instead.[11] The coverage gate judges freshness against as_of, not wall-clock, so a backdated pass gates sanely.

finding itself has no lifecycle. A finding row is born once in a persist_findings insert and is immutable - never updated, never given a status. It dies only by cascade: when an episode is soft-deleted, cascade_on_episode_delete walks finding_evidence backwards, removes the link, and if the finding has no remaining live evidence it deletes the finding - taking its insight and remaining evidence with it on the ON DELETE CASCADE.[33] This is the one place finding_evidence earns its 39,512 rows as more than support: it is the referential index that keeps the analysis layer honest when a member deletes data.

insight and knowledge_gap arcs

insight is a perpetual upsert: the current object per detector-metric, whose status/coverage_ok/gate_outcome are overwritten every pass and whose statement transitions '' → narrated text when the W3 narrator runs.[20]knowledge_gap is the only table here with an explicit open → resolved transition: opened while a shape calibrates, resolved_at stamped when it leaves.[23]

The member-state arcs

profile: created on first authenticated call, soft-deleted (deleted_at) by DELETE /profile, hard-purged 30 days later by the sweeper.[30]onboarding_state: init-on-first-GET, one current_step walked linearly through the STEPS tuple, completed_at stamped at the terminal step.[28]self_assessment: insert-only - a rating is captured, never updated or deleted, and no surface reads it back today (§9).

6. Populated example: a movement trend finding, walked to its evidence

A live finding from persona Clare Otter (profile_id2bec3a1e-…6913, party_locator dfa4c3b4-… - a fixture profile, not a real member), showing the detector→finding→evidence chain concretely.

The finding row

json
{
  "id": "dd64e8aa-7532-44db-bed2-13820c1b1a8f",
  "profile_id": "2bec3a1e-59a5-4bcc-ba85-94e01dae6913",
  "detector": "trend",
  "metric": "active_minutes",
  "shape": "movement",
  "method": "ols_slope_ttest",
  "value": 7.2857,
  "evidence_window": {"n": 7, "p": 0.0493, "r2": 0.5715},
  "confidence": 0.9507,
  "computed_at": "2026-06-02T18:23:20Z"
}
KeyRead byWhat actually happens
detector: trend, method: ols_slope_ttestdetect_trend[13]an OLS slope + t-test over Clare's daily active_minutes rollups fired because p = 0.049 < 0.05
value: 7.2857the insight cardthe slope: active minutes rising ~7.3 min/day over the window
evidence_window.n: 7discretize / the cardthe statistic ran on 7 daily rollups - not 7 episodes
confidence: 0.9507discretize (trend → marked when ≥ 0.99)1 - pvalue; here marked-adjacent but moderate
computed_at: 2026-06-02nothing on readone of many passes - this exact finding recurs across later passes (§9)

Its evidence (8 rows for a 7-day statistic)

finding_evidence links this one finding to 8 episodes, spanning 2026-05-27 to 2026-06-02:

episode_idepisode_typedayobservations
cc52d11d-…fitness_classes2026-05-271
b37c40c4-…strength_training2026-05-291
6042163b-…walking2026-05-312
42cebe34-…running2026-06-012
74269121-…walking2026-06-021
ab198088-…biking2026-06-021
569aeaab-…walking2026-06-021
5432f576-…running2026-06-021

This is the fan-out made concrete: the finding's statistic ran on 7 daily rollup values, but its evidence links the 8 episodes underneath, because 2026-06-02 alone holds four (two walks, a bike, a run). persist_findings took the full metric-carrying episode list for the window and wrote one finding_evidence row per episode.[9] Scale that up - a 60-day recovery window on a dense wearable persona - and one finding links hundreds of episodes, which is how 539 findings reach 39,512 evidence rows (max 610 on a single finding).

The insight object it emits

A trend-on-active_minutes finding upserts one current insight object. A live example (a different member, whose narrator has already run):

json
{
  "detector": "trend", "metric": "active_minutes",
  "status": "rising", "magnitude": "moderate",
  "coverage_ok": true, "gate_outcome": "pass",
  "formula_version": "trend@1.0", "window_days": 30, "n_observations": 14,
  "value": 4.0813,
  "sources": ["com.hevy","com.ouraring.oura","com.sec.android.app.shealth","movement_chat"],
  "statement": "You are building some great momentum with your active minute…"
}

discretize set status: rising / magnitude: moderate deterministically from value and confidence; gate_finding passed all four conditions (coverage_ok: true); sources records the distinct apps behind the evidence episodes; and the narrator filled statement from those already-decided facts.[18][20]

The member-state rows

A profile row (persona fixture; no PII beyond the display name):

json
{
  "id": "2bec3a1e-59a5-4bcc-ba85-94e01dae6913",
  "party_locator": "dfa4c3b4-a577-4d4b-99b8-c083188f4d78",
  "display_name": "Clare Otter",
  "created_at": "2026-06-02T00:02:49Z",
  "deleted_at": null
}

The single onboarding_state row is stuck mid-walk - it reached pillars.joy and never completed, its step_data accreting one captured activity per pillar (a meditation habit, a walk, an 8-hour sleep, one dropped "typical daily meals"):

json
{
  "profile_id": "664aa42c-…e2d8",
  "current_step": "pillars.joy",
  "completed_at": null,
  "step_data": {"pillars.sleep": {"captured": [{"kind":"episode","summary":"Slept 8 hours",}]}, }
}

A self_assessment row (source_text redacted - it holds the raw member transcript):

json
{ "pillar": "joy", "rating_text": "Social life is good", "source_text": "[redacted]" }

Live population for context (2026-08-21): 539 findings (144 distinct signatures) across 11 profiles - 209 trend, 144 period, 116 activity_level, 55 anomaly, 15 hrv_balance, by shape 317 sleep / 207 movement / 15 recovery; 39,512 finding_evidence; 60 insights (46 with a narrated statement, 14 still ''); 95 knowledge_gaps (all insufficient_data, 22 resolved); 35 profiles; 1 onboarding_state; 49 self_assessments (20 sleep, 13 mind, 7 joy, 5 movement, 4 eating).

7. Who references these tables

WhereColumn / mechanismMeaning there
insight.finding_idFK → findingthe current object points back at the finding it discretized
finding_evidence.finding_id / .episode_idcomposite FKthe evidence join; walked backwards by the delete cascade[33]
streams.alert.finding_idFK ON DELETE SET NULLan alert cites the finding that raised it; cascade annotates + nulls it on delete[33]
streams.plan.gap_idFK → knowledge_gap ON DELETE SET NULLa training plan may cite the gap it addresses (schema link; not populated by the emitter)
Insights API - GET /insights/bundle, GET /insightsreads insight LEFT JOIN findingthe gated fact bundle; the only input surface for the narrator + cards[31]
Insight detail - GET /insights/{id}reads insight JOIN finding, then finding_evidencethe only query-time reader of finding_evidence - returns the evidence episode ids[32]
QA / movement-chat agentsquery_findings(shape, detector, limit)the agent cites raw findings in chat; newest-first, capped at 50[24]
Visualise - GET /shapes/{shape} drill-downreads finding WHERE profile_id AND shapethe shape's detected patterns behind its score[37]
profileevery streams table's profile_id FKthe tenancy root; 19 tables cascade off it[6]
Onboarding routes - GET/POST /onboarding/*get_or_init_state / advance_statethe balance-native onboarding walk (1 live row)[27][29]
self_assessmentno reader todaywritten by the chat/onboarding ingest[26]; the reader that folds self-report into the analysis is the extension point in §9

Only insight.finding_id, finding_evidence, alert.finding_id and plan.gap_id are true in-schema FKs; profile.party_locator is a soft cross-service reference. Nothing outside balance reads these tables directly - the member app reaches them only through the Insights API.

8. Design determinations

  1. Detector contract is signal-or-silence; history lives in finding. Each detector is a pure function returning a DetectorFinding or None, floor-gated, with no DB access - trend/anomaly/period/correlation (DR-4.9, #1502) plus the W1 sleep/recovery/activity primitives.[12][13]
  2. The insight row is a self-describing object. #1503 (design doc #1491, the D-50 ingest ADR) made the insight the ONLY thing the narrator and cards ever see: discretized bands + coverage-gate verdict, decided deterministically.[4][18]
  3. Bands decided in code, prose by LLM. discretize fixes status/magnitude before any model runs; the narrator (#1505) translates an already-decided fact and never interprets a number - the guardrail against numeric hallucination.[18]
  4. An insight kind with nothing to report ships its reason, it does not vanish. emit_state_object and emit_placeholder_object cover the "member is doing well" and "no data yet" cases under the same upsert key, so the bundle always shows one object per kind with its gate_outcome.[21][22]
  5. Evidence is episode-level, and it is what keeps the analysis honest on delete. finding_evidence is a bare join table, but the delete cascade uses it to prune findings that lose their evidence when a member deletes an episode.[33]
  6. Knowledge gaps ship v1-minimal with the type space already open.emit_gaps writes insufficient_data (DR-4.17); gap_type is free text and the DDL comment reserves deficit, so the richer gap is an added branch in one function rather than a migration.[23][5]
  7. Batch, not real-time. Findings are produced by a nightly cron pass plus a debounced on-ingest hook, never synchronously on a member request - the engine is a scheduled recompute, by design.[34][36]
  8. Profile is provision-on-first-call, party is a soft reference. No sign-up step writes a profile; the first authenticated JWT does, idempotently, and party_locator is never FK-checked against the party service.[25]

9. Caveats and extensibility

Group and individual. These tables are strictly member-level. A profile is one person's streams identity keyed by party_locator; findings, insights, gaps and self-assessments all hang off profile_id. There is no scheme, no employer, no group concept anywhere in the analysis chain - a scheme member and a direct-to-consumer member produce identical rows, and group-ness lives entirely in the party/scheme services upstream. Wearable analysis is personal by construction, so the design serves both cohorts with no schema change.

Where to make the change, when the need lands. The analysis chain is deliberately open at both ends: a detector is a pure function behind one shared dataclass, and the finding row absorbs any detector's output because detector / method are free text and evidence_window is jsonb. The member-state tables carry capabilities the product has not yet routed through them. Each row below names the surface to touch.

When we need ...What to addWhere
a new detector (a new primitive over the same rollups)a module returning a DetectorFinding or None, a floor entry for it, a call site in the pass, and a discretize bandengine/detectors/[12], engine/floors.py[38], _persist_and_emit in engine/recompute.py[11], discretize[18]. No schema change - finding + finding_evidence take the rows as they are[1][2], and discretize falls through to status='observed' for a detector it does not know, so the insight object ships before its band is written[18]
correlation and rhr_trend to start producing findingsdata, not code: curated metric pairs for correlation, and a 60-day HR baseline matured by the ingest path for RHRboth detectors are built and called[16][17]; their floors (correlation 14 days, rhr_trend 21) keep them silent until coverage arrives, and balance.floors.<shape>.<detector> retunes that without a deploy[38]. This is a data-maturity threshold, and it clears as members accumulate history
a below-baseline deficit gap ("enough data, and it is short")a second branch in emit_gaps writing gap_type='deficit' with its own evidence payloadengine/gaps.py[23]; gap_type is free text and the DDL comment already reserves the value[5], so the change is additive
self-reported ratings to influence the analysisa reader over self_assessment - the member's own pillar rating set beside the computed shape_score, either as a card field or as a gate inputrows are already captured per (profile, pillar) by _store_self_assessment[26], with the CHECK fixing the five pillars and source_text carrying the transcript for provenance[8]
members onboarded through balance itselfnothing in the schema - point the client at GET/POST /onboarding/*onboarding/state.py[27][28] + onboarding/routes.py[29]. The step machine, the per-step step_data jsonb and the completion stamp are modelled and exercised; a new step is a STEPS entry, not a column. Today the member app drives onboarding in its own flow, so the table holds 1 live row
a different narrator, voice or modelswap the agent - the row contract does not moveagents/insight_narrator.py[39] writes only statement; status, magnitude, coverage_ok and value are decided before it runs[18][19]
a second posture (advice, not observation)a value + the routing rules behind itinsight.posture is free text defaulting 'observation'[3]; the column exists to make "balance observes" explicit and to leave room for the determination that changes it

Facts a reader should carry:

  • finding_evidence is the second-biggest table in the domain and mostly referential. 39,512 rows; the one query-time reader is the single-insight detail endpoint[32], and the rest of its work is being walked backwards by the delete cascade[33]. Write amplification is real (finding × full evidence set × every pass) and follows from the append-only finding defect below.
  • finding.metric / shape are nullable and always set. The DDL allows NULL; no live row carries one. A NOT NULL would tighten it at any time.
  • posture holds one value. Every insight is 'observation'; no writer sets anything else and no CHECK forbids one.
  • One legacy insight row predates the object model. 60 insights, 59 keyed by (profile, detector, metric); one pre-0017 row carries a NULL detector and is excluded from the bundle by the detector IS NOT NULL filter - harmless, and a reminder the object columns were added to an existing table.[3]

Known defects, and the fixes:

  • finding has no unique key, so every recompute re-inserts. 539 live rows collapse to 144 distinct (profile, detector, metric, value, window) signatures - one signature repeats many times, seconds apart across passes. The insight upsert hides it downstream (60 clean rows) and the delete cascade copes, but the raw table grows with pass count rather than with signal, and query_findings[24] hands an agent the same fact repeatedly. Fix: give finding the dedup key it lacks - a UNIQUE index on (profile_id, detector, metric, method, value, evidence_window) (or a hashed signature column) in a new migration alongside 0007_derived.py[1], and turn persist_findings's INSERT into ON CONFLICT DO UPDATE SET computed_at=now() in engine/findings.py[9], so a repeat pass refreshes the row instead of minting one. The evidence PK is already idempotent[2], so the fan-out shrinks with it.
  • 14 of 60 insight rows carry statement = '' with no record of why.narrate_profile is fail-soft by design - a missing API key, an agent error, or a statement dropped by the foreign-numeral validator all return without writing, and nothing on the row says which happened[39]. The card then renders its bands with no sentence, and the reason lives only in a log line. Fix: stamp the narration outcome on the row (a narration_status / narrated_at pair on streams.insight) and re-narrate rows that hold '' on the next pass, in agents/insight_narrator.py[39]. Gated objects legitimately stay empty - the narrator only loads pass-gate objects - so the fix must distinguish "not narrated because gated" from "narration failed".

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. migrations/versions/0007_derived.py#L43 - finding CREATE + finding_profile_shape_idx
  2. 0007_derived.py#L58 - finding_evidence CREATE (composite PK + episode index)
  3. 0007_derived.py#L67 - insight original CREATE (pre-object model)
  4. 0017_insight_objects.py#L36 - insight object columns + insight_current_object_idx (#1503)
  5. 0007_derived.py#L91 - knowledge_gap CREATE
  6. 0002_profile.py#L16 - profile CREATE + UNIQUE party_locator
  7. 0006_supporting.py#L42 - onboarding_state CREATE (PK = profile_id)
  8. 0010_self_assessment.py#L20 - self_assessment CREATE + pillar CHECK
  9. engine/findings.py#L11 - persist_findings: finding insert + evidence fan-out
  10. engine/recompute.py#L72 - detector loop + evidence-episode collection
  11. engine/recompute.py#L160 - _persist_and_emit: persist then upsert insight object
  12. engine/detectors/__init__.py#L7 - DetectorFinding shared dataclass
  13. engine/detectors/trend.py#L11 - detect_trend (OLS slope + t-test)
  14. engine/detectors/anomaly.py#L8 - detect_anomaly (z-score)
  15. engine/detectors/period.py#L8 - detect_period (descriptive run)
  16. engine/detectors/correlation.py#L10 - detect_correlation (Spearman; 0 live rows)
  17. engine/detectors/recovery.py#L49 - detect_rhr_trend (sustained z over 60d; 0 live rows)
  18. engine/insight_objects.py#L59 - discretize: value → (status, magnitude)
  19. engine/insight_objects.py#L105 - gate_finding: the four coverage conditions
  20. engine/insight_objects.py#L185 - emit_insight_object: current-object upsert
  21. engine/insight_objects.py#L137 - emit_state_object: affirmative state (finding_id NULL)
  22. engine/insight_objects.py#L227 - emit_placeholder_object: source_missing / insufficient
  23. engine/gaps.py#L9 - emit_gaps: open/resolve knowledge_gap (insufficient_data only)
  24. read/findings.py#L12 - query_findings: the agent-facing read
  25. profile.py#L16 - find_or_create_profile: provision-on-first-call
  26. ingest/multi.py#L68 - _store_self_assessment: the sole writer
  27. onboarding/state.py#L31 - get_or_init_state: init-on-first-GET
  28. onboarding/state.py#L56 - advance_state: step-machine advance
  29. onboarding/routes.py#L78 - POST /onboarding/answer
  30. surfaces/profile_routes.py#L48 - DELETE /profile: soft-delete + deletion_log
  31. surfaces/insights_routes.py#L45 - GET /insights/bundle: the gated fact bundle
  32. surfaces/insights_routes.py#L189 - GET /insights/{id}: the only finding_evidence reader
  33. cascade.py#L12 - cascade_on_episode_delete: prune findings that lose live evidence
  34. engine/scheduler.py#L25 - _daily_pass: nightly recompute of active profiles
  35. engine/scheduler.py#L91 - the cron registration (balance.engine.daily.cron)
  36. engine/scheduler.py#L126 - schedule_on_ingest: debounced fast path
  37. surfaces/visualise_routes.py#L56 - shape drill-down reads finding by shape
  38. engine/floors.py#L6 - _BASE + _OVR: the per-detector data floors, GrowthBook-overridable
  39. agents/insight_narrator.py#L148 - narrate_profile: fail-soft agent call + the three statement-rejection branches

Live-schema facts (row counts, detector/shape/method breakdowns, evidence fan-out min/avg/max, distinct finding signatures, insight gate-outcome census, knowledge-gap resolution, the worked Clare Otter finding + its evidence rows, the profile / onboarding_state / self_assessment samples, Debezium publication) come from PGPASSWORD=… psql -h 10.0.1.2 -U olly -d balance · \d streams.finding, \d streams.finding_evidence, \d streams.insight, \d streams.knowledge_gap, \d streams.profile, \d streams.onboarding_state, \d streams.self_assessment, 2026-08-21.

Olly Health Insurance Platform