Exercise Taxonomy & Pose Tracking — Design Decisions
Status: Decisions locked (2026-05-25)
All architectural decisions (D1–D21), deferred items (DF1–DF5), and brainstorm items (B1–B8) confirmed after Phase 2 walkthrough on 2026-05-25. This doc is now the authoritative source for the design; content folds into data-model.md (schema), decisions.md (rationale), features/exercise-library/ (product spec), telemetry/index.md (pose-side integration), and implementation-plan/features.md (F9.1 Phase 2 checklist updates). This doc stays as the canonical source for why these tables look the way they do.
Scope decision: taxonomy + pose-tracking ship together as F9.1 Phase 2 (one feature, sub-phases). See Integration plan for sequencing.
Context
The exercises catalog at services/api/migrations/core/000022_exercises.up.sql ships F9.1 Phase 1 — operational metadata only (slug, kind, manifest_version, laterality, has_pause, has_instructions, languages, capabilities, status, asset_version, Bunny media fields). The clinical/biomechanical taxonomy (body regions, equipment, categories, contraindications, instructions, translations) is already designed in data-model.md:854-900 and marked as F9.1 Phase 2 in implementation-plan/features.md — deferred from F9.1 Phase 1 launch, not forgotten.
Two trigger events forced this design session:
Stats / patient-progress build pass surfaced that several Clinic-app and Portal surfaces (per-protocol stats, pain map "Harta durerii", cohort scatter plots, per-region filtering) all depend on
target_regionstagging on exercises. Today these surfaces ship as preview cards with sample data. See stats-and-session-player-followups.md § 11.Pose-tracking UI mockups (Claude Design AI, no platform context) revealed that pose-tracking config is a mini-domain in its own right (6+ tables: configs, landmarks subset, metrics, feedback rules, calibration, history) — NOT a few columns on
exercises. This forces resolution of the deferred telemetry/index.md:180 decision: "exercisestable JSON column vs. dedicatedexercise_pose_modelstable — the engineer building F9 picks this with real exercise data in front of them."
Why both at once
The taxonomy and pose-tracking schemas are load-bearing on each other:
- Body regions and movement patterns drive landmark selection and rep-counting heuristics on the pose-engine side.
- The Class IIa provenance pattern (audit columns) applies identically to both.
- The "Sofia auto-config" UX (deferred) needs body_region + movement_pattern as input to suggest landmarks/metrics.
- Shipping taxonomy without pose-tracking forces a re-tag pass when pose-tracking lands.
MDR posture context
The platform's pose pipeline is positioned as Class I MDR today (CLAUDE.md → Medical Device Readiness), designed for Class IIa upgrade if/when pose measurements drive treatment decisions. The platform's stated direction is to become Class IIa in the future. Class IIa elevation imposes IEC 62304 §7.3 traceability requirements on any data structure that feeds clinical scoring — provenance and clinical-basis fields must be in place BEFORE any catalog row exists, because backfilling provenance across thousands of entries post-launch is operationally infeasible.
This design treats Class IIa readiness as a mandatory cost even though Class I posture is sufficient today. Adding the provenance columns (D3) now is cheap (pre-prod); adding them later is catastrophic. Vocabulary versioning (originally D4) is the optional enhancement — deferred to Class IIa preparation work because the lighter per-tag deprecation approach satisfies Class IIa requirements with an easy upgrade path.
Decisions made
Each decision below has: what (the call), why (the rationale), and folds into (where the decision lives in the long-term docs).
Taxonomy decisions
D1 — Extend the locked F9.1 Phase 2 design, do not rebuild
The taxonomy schema in data-model.md:854-900 (exercise_categories, exercise_body_regions, exercise_equipment, exercise_tags, exercise_instructions, exercise_contraindications) is locked and correct as a baseline. This design extends it with new axes and Class IIa columns; it does NOT redesign what's already there.
- Why: the locked design already incorporates P49 (dual-scope), P21 (translations JSONB), P24 (polymorphic junctions). Re-litigating those choices wastes the prior design work.
- Folds into: data-model.md Area 9 — additive edits, no deletions.
D2 — New taxonomy axes to add beyond the locked design
Four new axes surfaced during the stats / pose-tracking review (effort_tier resolved separately by B6):
| Axis | Shape | Why needed | Storage |
|---|---|---|---|
movement_pattern | Tag entity, M2M to exercise via exercise_tags(tag_type='movement_pattern') | Clinical reasoning (complementary patterns, duplicate detection); pose-engine rep-counting heuristic source | New exercise_movement_patterns table |
recovery_phase | Tag entity, M2M to exercise | Programming logic (acute / subacute / strength / maintenance); clinical scheduling | New exercise_recovery_phases table |
conditions (lookup) | Tag entity, M2M to exercise | Replaces freetext condition_name on exercise_contraindications; enables "exercises indicated for diagnosis X" queries | New exercise_conditions table |
skill_prerequisites | Tag entity, M2M to exercise (and self-M2M between exercises for "Bird Dog before Side Plank" chains) | Operational filter for what a patient can actually do; program-builder ordering | New exercise_skill_prerequisites table + exercise_prerequisites(exercise_id, prerequisite_exercise_id) |
Concrete values per axis (illustrative, not exhaustive):
- movement_pattern:
push | pull | squat | hinge | rotation | lunge | carry | gait | hold(isometric) - recovery_phase:
acute | subacute | strength | return_to_activity | maintenance - conditions:
Lombalgie (M54.5) | Cervicalgie (M54.2) | Meniscopatie (M23.x) | Post-ACL (Z98.89) | Rotator cuff impingement (M75.4) | Coxartroza (M16.x) | Proteză totală genunchi (Z96.651) | Epicondilita laterală (M77.1) | ... - skill_prerequisites:
balance_static | balance_dynamic | single_leg_stance | floor_to_stand | grip_strength | bilateral_coordination | weight_bearing_tolerance | core_endurance
Each axis answers a distinct clinician question:
| Axis | Question it answers |
|---|---|
body_region (locked) | Where on the body? |
movement_pattern | How does the body move? |
recovery_phase | When in recovery is this appropriate? |
conditions | What diagnosis is this for? |
skill_prerequisites | Can the patient actually do it safely? |
equipment (locked) | What do they need at home? |
difficulty (locked enum) | How hard is it overall? |
- Why M2M (tag pattern) consistently: an exercise is multi-region (deadlift = lumbar + glutes + hamstrings), multi-pattern (squat = squat + hinge), multi-phase (some exercises span acute → strength). Single-column wouldn't fit.
- Folds into: data-model.md Area 9 — new table definitions;
exercise_tags.tag_typeENUM extended.
D3 — Class IIa provenance columns on every tag-association table
Every join-table row (exercise_tags, exercise_contraindications, exercise_instructions, plus all pose-tracking association tables) carries three columns:
| Column | Type | Class I use today | Class IIa use future |
|---|---|---|---|
tagged_by_principal_id | UUID NOT NULL REFERENCES principals(id) | Audit trail (already a platform convention) | Required provenance per IEC 62304 §7.3 |
tagged_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | Audit trail | Required provenance |
clinical_basis | TEXT NULL | Optional documentation field | Becomes NOT NULL at Class IIa (one-line migration) |
- Why now: retroactive backfill across thousands of catalog rows is operationally infeasible. The column add is cheap pre-prod, catastrophic post-prod.
- Why principal-typed: uses the existing actor model (CLAUDE.md → Actor Model), so AI Principals (e.g., the deferred Sofia auto-config flow) can be the tagger; auditor sees what kind of actor authored each tag.
- Folds into: data-model.md Area 9 (column additions), decisions.md (new "Why Class IIa columns on day one" entry), data-classification.md (registry entries for the new columns).
D4 — Per-tag deprecation (replacing original full vocabulary versioning)
Instead of a dedicated taxonomy_versions snapshot table (originally proposed), every tag entity gets per-tag deprecation columns + a discipline rule:
| Column added to each tag entity | Type | Notes |
|---|---|---|
deprecated_at | TIMESTAMPTZ NULL | When this tag stopped being recommended for new tagging |
replaced_by_id | UUID NULL (self-FK) | What tag to use instead, if any |
Discipline rule (enforced by DB trigger): tag definitions are never modified in place. To "change" a tag, deprecate the old one and create a new one. This + per-tag deprecation gives Class IIa traceability without needing a vocabulary snapshot:
- "What did
lumbarmean for sessions tagged at time T?" → look at the tag row directly (definitions don't change in place; checkdeprecated_atvs T) - Historical sessions keep working: deprecated tags remain in the DB, hidden from new tagging UI
- Cohort analytics: queries can opt-in to "include deprecated tags" for historical comparisons
Upgrade path to full versioning (if Class IIa preparation later requires a documented vocabulary version artifact):
- Add
taxonomy_versionstable - Backfill it from per-tag
deprecated_attimestamps - Add
taxonomy_schema_versionFK to association rows, backfilled fromtagged_atlookup - Coordinate future vocabulary changes as version-bumped batches
This is mechanical and doesn't require any data reconstruction — tagged_at already tells us when each association was made.
- Why lighter is right today: the heavier
taxonomy_versionstable adds zero value to 99% of queries (which want current meaning, not historical), and the per-tag approach is sufficient for Class IIa traceability. The Class IIa upgrade path is easy and mechanical. - Why "never modify in place" matters: without this discipline, the per-tag approach breaks down — definitions change silently and historical interpretation becomes impossible.
- Folds into: data-model.md Area 9 (per-tag columns + DB trigger), decisions.md (rationale + why we deferred full versioning).
D5 — Which axes get org-private extension
Two-tier P49 ownership is the locked default for tag entities. But not every axis should allow org-private rows — some need vocabulary lock for cohort analytics to be meaningful.
| Axis | Platform-curated | Org-private extension | Why |
|---|---|---|---|
body_regions | ✅ | ❌ | Cohort analytics require comparable vocabulary across clinics |
movement_patterns | ✅ | ❌ | Pose-engine rep-counting heuristics map to these; org redefinition breaks engine integration |
recovery_phase | ✅ | ❌ | Comparable across clinics; small enum (4-5 values) |
skill_prerequisites | ✅ | ❌ | Used in algorithmic program suggestion; vocabulary lock matters |
categories | ✅ | ✅ | Organizational groupings — clinic-specific protocols, internal naming |
equipment | ✅ | ✅ | Clinics may have unique equipment ("Clinic A's proprietary brace") |
conditions | ✅ | ✅ | Clinics may track local condition names not in platform catalog |
- Why some axes lock and others extend: the cost of a fragmented vocabulary is cohort analytics becoming nonsensical. The benefit of org-private extension is letting clinics track local concepts. Lock the axes where the cost > benefit.
- Escape hatches for clinics on locked axes: translations (P21) let clinics rename platform tags in their local UI; platform vocabulary additions can be requested and evaluated by a clinical reviewer.
- How enforced: for locked axes, CHECK constraint
organization_id IS NULL. For extensible axes, P49 dual-scope applies as designed. - Folds into: data-model.md Area 9 (CHECK constraints per table), features/exercise-library/ (admin UX implications).
Pose-tracking decisions
D6 — Pose-tracking config lives in dedicated tables, NOT JSONB
The open question in telemetry/index.md:180 is resolved: dedicated tables.
- Why: the UI mockup decomposes into 6+ entities (config row, landmark subset, metric list with typed fields, feedback rules with severity, rep success rule, calibration). JSONB cannot be FK-validated, cannot be indexed for per-metric queries, cannot have its own Class IIa provenance columns per nested item, cannot be migration-evolved cleanly.
- Folds into: telemetry/index.md:180 (closes the deferred decision), data-model.md Area 9 (new pose-tracking entity section).
D7 — Pose-tracking schema designed concurrently with taxonomy, NOT deferred
- Why: taxonomy and pose-tracking are load-bearing on each other (see Context). Shipping taxonomy alone forces a re-tag pass when pose-tracking lands. The Class IIa columns are identical for both; designing them together produces one consistent provenance pattern.
- What this does NOT mean: pose-tracking schema doesn't have to ship at the same MIGRATION as taxonomy. The schemas are DESIGNED together; the build order can stage taxonomy first, pose-tracking second, with both designs locked. See Integration plan for the actual sequencing.
- Folds into: implementation-plan/features.md F9.1 Phase 2 (single feature, sub-phases).
D8 — Pose config is 1:1 with exercise + versioning via history table
One active pose config per exercise; prior versions live in exercise_pose_config_history.
- Why 1:1: if an exercise has a different filming/camera setup, it's a different exercise (different
asset_versiontriggers a new exercise variant or replaces the existing one). Multiple active configs per exercise complicates analytics ("which config produced this session's score?") with no concrete near-term use case. - Why history table: every config save (status: draft → published, or any edit to a published config) creates an immutable history row. Class IIa requires that historical scoring be reproducible — given a session_run, we must be able to look up exactly what pose config was active at run time.
- Easy escape hatch: if a real 1:N case appears later (lateral for flexion + frontal for rotation), we lift 1:1 to N with
is_active BOOLEAN+ partial unique index. One-line migration. The reverse direction (N back to 1) is much harder after data exists. - Folds into: data-model.md Area 9 (new tables), decisions.md (1:1 rationale).
D9 — Pose config pins to asset_version; new asset_version invalidates; clones copy
When exercises.asset_version bumps (new filming, recut, new framing), the existing exercise_pose_configs row is auto-marked status='invalidated' and the exercise reverts to tracking_enabled=false until a new pose config is authored.
The rule simplified:
| Event | Pose config? |
|---|---|
Asset unchanged (same asset_version) | Reused as-is, no re-validation |
Asset re-encoded but not re-filmed (should not bump asset_version) | Config reused |
Asset re-filmed (new shoot, new framing) — bumps asset_version | Config invalidates, requires re-authoring |
| Pose config edited (clinician tweaks a threshold) | New exercise_pose_config_history row, config stays VALID (versioning, not invalidation) |
Strict invalidation: any asset_version bump triggers invalidation. Re-encoding without re-filming should not bump asset_version in the first place — that's the contract.
Clone behavior (added during walkthrough): when an exercise is cloned (e.g., org cloning a platform exercise per P49), the pose config is copied with it. The cloned config gets a new id, points at the cloned exercise, inherits the original's pinned_asset_version, and Class IIa columns reflect the cloning principal (tagged_by_principal_id = cloning principal, clinical_basis = 'cloned from {original_id}').
- Why: landmark positions are relative to camera framing. A re-filming shifts where joints appear in frame; thresholds that worked for the old asset will not work for the new one. Auto-invalidation prevents silent scoring drift.
- Concretely:
exercise_pose_configs.pinned_asset_version INT NOT NULL+ DB trigger onexercises.asset_versionUPDATE that flipsstatusand emits a "pose config invalidated" event for the authoring UI. - Folds into: data-model.md Area 9 (column + trigger + clone copy logic), features/exercise-library/ (re-validation UX).
D10 — Form errors + live warnings collapse into one table (bare version)
The mockup showed two separate sections ("Erori de formă detectate" and "Avertismente live") but they're the same concept at different severity levels. Collapsed into a single table.
- What: single table
exercise_pose_feedback_ruleswithseverityenum (warning | critical | stop). - Bare version scope: ship without rate-limiting columns (
min_interval_seconds,trigger_once_per_rep). Default engine behavior: trigger on every frame the condition is true; client de-dupes within a short window. Rate-limiting columns added as nullable additive migration when real "too noisy" complaints surface. - Why one table not two: semantically identical (both are: condition → patient-facing message). Two tables would force duplicated columns and a synthetic "which one am I editing" UI state with no analytical benefit.
- Authoring UI implication: two stacked sections in the editor (warning rules grouped, critical/stop rules grouped) is fine — that's a presentation choice; the data is one table.
- Folds into: data-model.md Area 9 (single table, bare version), features/exercise-library/ (collapsed UI).
D11 — Drop "strictness" axis (mockup artifact)
The mockup's "Strictețe validare: Permisiv / Normal / Strict" axis has no defined semantics distinct from per-metric tolerance.
- Why dropped: if strictness is a tolerance multiplier, it duplicates per-metric
tolerance. If it's something else, the mockup doesn't say what. Add only when there's a concrete clinical meaning to attach. - Folds into: N/A (decision not to add).
D12 — Model quality metrics NOT in authoring schema
The mockup's "ACOPERIRE SESIUNI 96%" and "CONFIDENȚĂ MODEL 0.93" are aggregated telemetry, not author-editable config.
- What: these belong in
exercise_pose_validation_runsor a materialized view sourced from telemetry — separate fromexercise_pose_configs. - Why: mixing author-set config with engine-computed aggregates creates write-conflict surface area (who owns the row?) and confuses the audit story.
- Defer: the validation_runs table shape depends on what the pose aggregator computes. See DF2.
- Folds into: telemetry/index.md (validation runs section, post-aggregator).
D13 — Promotion threshold lives at session/program layer, NOT per-exercise
The mockup's "Prag de promovare: 75% repetări în țintă" is a session/program scoring rule, not a per-exercise config.
- Why: promotion ("can the patient advance to the next phase?") is a program-level clinical decision combining multiple exercises and multiple sessions. Per-exercise thresholds would not compose into a coherent program-level scoring rule.
- Where it lives: on
program_phasesorprotocols— depends on existing program/phase scoring model. Out of scope for this design doc. - Folds into: features/programs-and-assignments/ (when programs gain scoring rules).
D14 — Manifest stays clinical-metadata-free
The composer's manifest schema explicitly excludes clinical/UI metadata; this design respects that separation.
- Why: services/media/internal/manifest/types.go:83 and features/exercise-library/composition.md:95 are explicit: "Clinical / UI metadata stays in the DB, never the manifest."
- Implication: adding
body_regions, pose configs, etc., does NOT bumpmanifest_versionorasset_version. These are pure API schema migrations with no composer involvement. - Folds into: no doc changes needed; reinforces existing decision.
Pose-tracking content decisions (the "adopt" list)
These items from the mockup are clinically/operationally grounded and shipped in the schema.
D15 — Per-exercise tracking on/off + engine identifier
Two fields on exercise_pose_configs:
| Field | Type | Notes |
|---|---|---|
tracking_enabled | BOOLEAN NOT NULL DEFAULT FALSE | Master switch — pose tracking active for this exercise? |
engine_id | UUID NOT NULL FK → pose_engines(id) | Which pose engine this config targets |
pose_engines reference table:
| Column | Notes |
|---|---|
id | UUID PK |
code | TEXT UNIQUE (mediapipe.holistic, mediapipe.pose, future) |
display_name, vendor, version | TEXT |
landmark_catalog_version | INT — which version of the engine's landmark vocabulary |
status | enum (`active |
- Why
tracking_enabledas a separate flag: lets a clinician author + draft a pose config without activating it; lets a config exist ininvalidatedstatus without tracking attempts; cleaner UX. - Why
engine_idas FK: multi-engine future, per-engine landmark catalog versioning, engine deprecation lifecycle, Class IIa traceability all benefit from a reference table over a hardcoded string. - For F9.1 Phase 2: seed with
mediapipe.holisticonly. All initial configs target that engine. - Folds into: data-model.md Area 9.
D16 — Camera setup (angle, distance, lighting)
Fields on exercise_pose_configs capturing the camera calibration the patient needs:
| Field | Type | Notes |
|---|---|---|
camera_angle | ENUM (`frontal | lateral |
camera_distance_cm_min | INT NULL | NULL = no minimum |
camera_distance_cm_max | INT NULL | NULL = no maximum |
lighting_requirement | ENUM (`frontal | ambient |
in_frame_requirements | TEXT[] | Multi-select from a fixed vocabulary, e.g. ['fata_integral_vizibila', 'umeri_in_cadru'] |
- Why structured: patient-facing setup instructions need to be deterministic and translatable (RO/EN at launch). Freetext would force every clinic to author their own variant.
- Folds into: data-model.md Area 9.
D17 — Landmark subset per exercise (with reference catalog)
Two-part design:
Reference table pose_landmarks:
| Column | Notes |
|---|---|
id | UUID PK |
engine_id | UUID FK → pose_engines(id) |
code | TEXT (e.g., nose, left.shoulder, right.knee) |
display_name, display_name_translations | TEXT, JSONB (P21) |
body_part_category | enum (`head |
status | enum (`active |
| Unique | (engine_id, code) |
Seeded with the MediaPipe holistic catalog (~543 landmarks: 33 pose + 21 left hand + 21 right hand + 468 face). Most exercises use a tiny subset (3-10).
Per-config join table exercise_pose_landmarks:
| Column | Notes |
|---|---|
exercise_pose_config_id | UUID FK |
landmark_id | UUID FK → pose_landmarks(id) |
Class IIa: tagged_by_principal_id, tagged_at, clinical_basis | Per D3 |
| PK | (exercise_pose_config_id, landmark_id) |
- Why a subset: "fewer landmarks = more stable tracking" — running detection on only relevant joints reduces noise and CPU.
- Why a reference table: different engines have different landmark sets; schema can't hardcode MediaPipe's vocabulary. Engine upgrades add/deprecate landmarks. Display names translate (P21).
- Folds into: data-model.md Area 9, telemetry/index.md (engine integration).
D18 — Metrics list (target, tolerance, weight, source landmarks, axis)
exercise_pose_metrics per-row schema:
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
exercise_pose_config_id | UUID FK | |
metric_type | ENUM | Per B7: `angle |
target_min, target_max | NUMERIC | E.g., 60-80 degrees |
tolerance | NUMERIC | E.g., ±5 |
weight_pct | INT CHECK (weight_pct BETWEEN 0 AND 100) | Contribution to overall success score; per-config weights sum to 100 (validated at app layer or deferred constraint trigger) |
landmark_refs | UUID[] | Source landmarks for direct-measurement metrics (NULL for derived metrics) |
derived_from_metric_ids | UUID[] NULL | Sibling metric IDs for composite/derived metrics (e.g., symmetry references rotL + rotR); NULL for direct-measurement metrics |
axis | ENUM (`x | y |
label, label_translations | TEXT, JSONB | Patient/clinician-facing name |
Class IIa: tagged_by_principal_id, tagged_at, clinical_basis | Per D3 |
- Why structured: the mockup showed targets like "ROM 60-80° ±5, weight 40%, source nose + shoulder-mid, axis z" — that's the data shape.
- Why both
landmark_refsandderived_from_metric_ids: symmetry metrics reference OTHER metrics (rotL, rotR), not landmarks directly. Separate column avoids polymorphic confusion. - Folds into: data-model.md Area 9.
D19 — Feedback rules (collapsed form-errors + live-warnings) — bare schema
exercise_pose_feedback_rules per-row schema (bare version per D10):
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
exercise_pose_config_id | UUID FK | |
severity | ENUM (`warning | critical |
condition_expression | TEXT | Engine-parseable DSL — see DF1 |
condition_format | ENUM default 'text_v1' | Discriminator for the DSL version |
patient_message, patient_message_translations | TEXT, JSONB | What the patient sees |
Class IIa: tagged_by_principal_id, tagged_at, clinical_basis | Per D3 |
Excluded from bare version (added later as additive nullable columns when real usage demands):
min_interval_seconds(rate limiting)trigger_once_per_rep(rate limiting)Aggregation rules (e.g., "trigger only if N consecutive frames true")
Folds into: data-model.md Area 9.
D20 — Rep success criteria as structured rule
The "rotation ≥60° each side, returns to 0±5°" rule needs structure, not freetext. Per B8: ENUM type + JSONB params.
| Column | Type | Notes |
|---|---|---|
rep_success_rule_type | ENUM (`angle_cycle | position_cycle |
rep_success_rule_params | JSONB | Type-specific params (start_angle, target_angle, return_angle, tolerance, etc.) |
Example params for cervical rotation:
{
"rule_type": "angle_cycle",
"side": "both",
"target_angle_min": 60,
"target_angle_max": 80,
"return_angle": 0,
"return_tolerance": 5
}- Why JSONB for params: each rule type has different params; a JSONB with a type discriminator + strict Go validator at the application layer balances flexibility with safety.
- Composite handling: nested JSONB tree (
{operator: 'AND'|'OR', children: [...]}); refactor to a tree-structured table only if real composite rules get hairy. Easy upgrade. - Class IIa:
rep_success_rule_paramsis structurally validated AND audit-logged on every edit. - Folds into: data-model.md Area 9.
D21 — Calibration requirements (covered by D16)
Camera setup + lighting + in-frame requirements collectively form "calibration." All live on the exercise_pose_configs row via D16's fields, no separate table needed. The patient-facing calibration checklist UI reads from those same columns.
Deferred items
Each deferred item has: what (what's deferred), why (why it can't be decided now), and trigger (what event causes it to be revisited).
DF1 — Condition expression DSL
- What: the exact grammar for
condition_expression(e.g.,spine angle > 30°,knee over toes). - Why deferred: the parser lives in the pose-aggregation engine (telemetry/index.md, "deferred pose-frames pipeline"). We can't formalize syntax until we know what the engine can actually parse.
- Schema impact today: field is
TEXTwithcondition_format ENUMdiscriminator. Default totext_v1. When DSL ships, new rows tagtext_v2; old rows keeptext_v1. No backfill needed. - Trigger to revisit: when pose-aggregation engine ships (F-tier, currently unscheduled).
DF2 — Validation metrics shape
- What: schema for
exercise_pose_validation_runs(coverage %, confidence score, validated-on-N-sessions count). - Why deferred: depends on what the aggregator computes per session and how validation is performed (manual clinical annotation? automated cross-check? both?). Likely shaped by Class IIa preparation work.
- Schema impact today: none. Authoring schema does not include these.
- Trigger to revisit: when validation methodology is defined (likely Class IIa preparation phase).
DF3 — Sofia AI auto-config flow
- What: the "Sofia: configurează automat din video" button — AI that suggests pose config from a sample video.
- Why deferred: complete subsystem (vision model + LLM-driven config synthesis + clinical-reviewer approval flow); no schema decisions force it now. May have been a designer hallucination — the mockups had no platform context.
- Schema impact today: the
tagged_by_principal_idcolumn already supports AI Principals as the tagger when this ships. - Trigger to revisit: when an AI authoring agent is scoped (currently no plan).
DF4 — Per-vocabulary org-private extension UI (Console)
- What: the Console UI for clinic admins to manage org-private tags on the extensible axes (categories, equipment, conditions per D5). Schema fully supports org-private rows; authoring UI is what's deferred.
- Why deferred: schema supports it via
organization_id NULL(per D5); UI authoring effort can wait until first clinic explicitly asks for the capability. Manual workaround: platform team creates org-private tags via direct API call on request. - Schema impact today: none — schema already supports it; CRUD API endpoints can ship; only the Console UI is deferred.
- Trigger to revisit: first clinic explicit feature request, OR Console redesign cycle.
DF5 — Specialist signoff / approval workflow for contraindications (defer everything)
- What: the entire
review_statusenum +reviewed_by_principal_id+reviewed_atcolumns + author/reviewer/approver workflow forexercise_contraindicationsand pose configs. No placeholder columns either. - Why deferred: contraindication authoring is the highest-liability axis. Class I posture handles this informally (clinical reviewer is internal). Class IIa requires a formal documented workflow design, dependent on regulatory consultation that hasn't happened yet. The schema work is genuinely cheap when needed (~1 hour migration); the expensive work is UI/RBAC/process and isn't reduced by shipping a placeholder column.
- Schema impact today: zero. No columns added now. When Class IIa workflow lands: add
review_status+reviewed_by_principal_id+reviewed_at+ (maybe)submitted_by_principal_id+submitted_atas a single additive migration. - Trigger to revisit: Class IIa preparation kickoff (with regulatory counsel in the room), OR first contraindication-related incident.
Locked brainstorm decisions
All brainstorm items resolved during Phase 2 walkthrough (2026-05-25).
B1 — Feedback rate-limiting → Resolved by D10
D10's bare-version scoping resolves this — min_interval_seconds and trigger_once_per_rep are NOT shipped at F9.1 Phase 2. Added as additive nullable columns when real usage demands them. The sub-questions (one field vs both, default behavior, engine vs client handling) get answered with real session data in front of us, not preemptively.
B2 — Tracking-failure handling → Locked
Decision:
- Per-config threshold: add
min_landmark_confidence NUMERIC NOT NULL DEFAULT 0.5 CHECK (>=0 AND <=1)toexercise_pose_configs - Per-frame aggregate: engine computes mean confidence across the active landmark subset (D17); compare aggregate to threshold
- Patient UI: neutral state ("Hold still, adjusting camera") when tracking lost — NOT a red error
- Engine behavior: after N consecutive frames below threshold → emit "tracking lost" event, pause rep counting, don't penalize patient score
- Analytics: session_run-level "% frames above confidence threshold" tracked in
exercise_pose_validation_runs(deferred per DF2)
Schema today: one column on exercise_pose_configs. Engine behavior + analytics lives in telemetry-side work (deferred).
B3 — Specialist clinical override of session pose data → Locked
Decision: new table pose_data_quality_overrides with session_run + session_exercise_event granularity (NOT per-rep).
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID FK NOT NULL | Standard tenant scoping |
scope | ENUM (`session_run | session_exercise_event`) |
session_run_id | UUID FK NOT NULL | The session being overridden |
session_exercise_event_id | UUID FK NULL | Set when scope = session_exercise_event |
override_reason | TEXT NOT NULL | Specialist's clinical rationale |
overridden_by_principal_id | UUID FK NOT NULL → principals(id) | Auditable |
overridden_at | TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
created_at | TIMESTAMPTZ |
CHECK constraint: (scope = 'session_run' AND session_exercise_event_id IS NULL) OR (scope = 'session_exercise_event' AND session_exercise_event_id IS NOT NULL)
Effect: stats queries (per-patient, cohort) exclude overridden data via WHERE NOT EXISTS. Promotion threshold (D13) excludes overridden reps from rep_success counts. Raw pose data stays in DB (audit intact) but doesn't feed scoring.
Audit: every override insert audit-logged with full principal_id + reason. Class IIa requirement.
What ships now vs deferred:
- Now: table + schema + audit logging
- Deferred: Console UI for specialists to actually invoke the override. Initial workflow = support request to platform team (same pattern as DF4).
B4 — Disabled-metric handling when source landmarks toggled off → Locked
Decision: block-removal approach at the application layer.
- When a clinician tries to remove a landmark from the active subset (D17) that's referenced by any
exercise_pose_metrics.landmark_refsorexercise_pose_metrics.derived_from_metric_idschain, the Go service rejects the update with a clear error: "Landmark X is used by metric Y; drop the metric first." - Same rule applies to deleting metrics that are referenced by composite/derived metrics (
derived_from_metric_ids). - Why block (not auto-disable or cascade-delete): auto-disable is invisible failure (Class IIa traceability nightmare); cascade-delete is destructive (author loses work).
- Where validation lives: application layer (Go service), not DB constraint — since
landmark_refsisUUID[], real FK isn't possible. - Schema impact: none —
landmark_refsandderived_from_metric_idsalready in D18.
B5 — Per-condition vocabulary source → Locked
Decision: Option (c) — platform names canonical with optional ICD-10 mapping.
exercise_conditions table:
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID NULL | Dual-scope per D5 |
slug, display_name | TEXT NOT NULL | Platform-curated canonical name (Romanian-first) |
display_name_translations | JSONB | P21 |
description | TEXT NULL | |
icd10_code | TEXT NULL | Optional external mapping for interop |
body_region_id | UUID NULL FK → exercise_body_regions | Optional clinical grouping |
status | TEXT NOT NULL DEFAULT 'active' | `active |
deprecated_at | TIMESTAMPTZ NULL | Per D4 |
replaced_by_id | UUID NULL (self-FK) | Per D4 |
| Standard audit (created_by_principal_id, created_at, updated_at) | Class IIa provenance cols (D3) live on the association rows (exercise_tags, exercise_contraindications), NOT on this entity row — the audit question is "who tagged exercise X with condition Y" (association), not "who created the condition Y concept" (entity, covered by standard audit) |
- Why option (c): clinicians work with clinical names not ICD-10 codes; Class IIa happy because codes are present when needed for regulatory submissions; Romanian insurance billing uses ICD-10 (future-proofs EHR/insurance integrations); nullable means lazy population (start with common conditions).
- For F9.1 Phase 2: seed with ~30-50 most common rehab conditions in Romanian, with ICD-10 codes where known.
B6 — effort_tier vs. existing difficulty enum → Locked
Decision: keep the locked difficulty enum (beginner | intermediate | advanced); do NOT add a separate effort_tier.
- Day-to-day specialist conversation uses 3-tier difficulty, not numeric scales
recovery_phase(D2) is a separate axis already covering a different dimensionskill_prerequisites(D2) handles capability-gating granularity- If finer granularity needed later, refining
difficultyenum is a one-line migration - Patient-perceived effort tracking already exists on
patient_session_completions.perceived_difficulty INT 1-5— separate from exercise-intrinsic difficulty
Schema impact: zero — no change to the locked design.
B7 — metric_type enum values → Locked
Decision: 7-type initial set.
| metric_type | What it measures | Example exercise |
|---|---|---|
angle | Angle between three landmarks (joint flexion) | Knee flexion in squat |
rom | Range of motion (max - min over rep) | Cervical rotation ROM |
symmetry | Difference between two sibling metrics (L vs R) | L-R rotation symmetry |
distance | Distance between two landmarks | Hand-to-knee touch |
velocity | Rate of change of position/angle | Jumping, ballistic |
position | Absolute position relative to reference (axis-aligned) | Hip drop in plank |
hold_time | Duration above/below threshold | Static plank hold |
Deferred to later additive migrations: acceleration, frequency, path_length, relative_position.
Caveat: confirm with engine team when they start work that all 7 types are parseable. None should be blockers but worth a sanity check before the schema ships.
B8 — Rep success rule shape → Locked
Decision: 4-type initial set, composite as nested JSONB.
| rule_type | What counts as a rep | params JSONB shape |
|---|---|---|
angle_cycle | Patient reaches target angle, returns to start | {start_angle, target_angle, return_angle, tolerance, side?} |
position_cycle | Body part reaches target position, returns | {landmark_id, target_xyz, return_xyz, tolerance} |
velocity_peak | Peak velocity detected on dominant axis | {landmark_id, axis, peak_threshold, min_amplitude} |
composite | Multiple sub-rules ANDed (tree structure) | `{operator: 'AND' |
Composite as nested JSONB (not a separate tree table): uncommon rules; JSONB tree easy to author and validate; refactor to a tree table only if real composite rules get hairy.
Caveat: same as B7 — confirm with engine team that all 4 types are parseable.
Final schema sketch (entity-level)
Taxonomy entities (extends data-model.md Area 9)
LOCKED (already in data-model.md):
exercise_categories (dual-scope, hierarchical via parent_id, translations)
exercise_body_regions (LOCK TO PLATFORM-ONLY per D5; CHECK organization_id IS NULL)
exercise_equipment (dual-scope)
exercise_tags (polymorphic junction; tag_type ENUM extended per D2)
exercise_instructions (per-exercise typed steps; Class IIa cols added per D3)
exercise_contraindications (per-exercise; condition_name → condition_id FK per B5;
Class IIa cols added per D3)
NEW (per D2 + D5):
exercise_movement_patterns (platform-only; deprecated_at + replaced_by_id per D4)
exercise_recovery_phases (platform-only; deprecated_at + replaced_by_id per D4)
exercise_conditions (dual-scope; nullable icd10_code per B5;
deprecated_at + replaced_by_id per D4)
exercise_skill_prerequisites (platform-only; deprecated_at + replaced_by_id per D4)
exercise_prerequisites (self-M2M between exercises: prerequisite_exercise_id)
DEFERRED (per D4 upgrade path):
taxonomy_versions (NOT shipped — replaced by per-tag deprecation columns;
easy backfill if Class IIa later demands)Pose-tracking entities (NEW per D6-D21, B2-B4, B7-B8)
REFERENCE TABLES (engine vendor data):
pose_engines (mediapipe.holistic, mediapipe.pose, future)
pose_landmarks (per-engine landmark catalog, ~543 for MP holistic;
deprecated_at for engine vocabulary evolution)
PER-EXERCISE CONFIG (1:1 per D8):
exercise_pose_configs (main row: tracking_enabled, engine_id, camera setup,
rep_success_rule_type + JSONB params,
pinned_asset_version, min_landmark_confidence,
status, Class IIa cols per D3)
exercise_pose_config_history (immutable prior versions; full row snapshots)
PER-CONFIG DETAIL (1:N from config):
exercise_pose_landmarks (M2M to pose_landmarks; Class IIa cols per D3)
exercise_pose_metrics (typed metrics: target/tolerance/weight/landmark_refs/
derived_from_metric_ids/axis; Class IIa cols per D3)
exercise_pose_feedback_rules (severity + condition + patient message;
bare version per D10; Class IIa cols per D3)
OVERRIDE TABLE (per B3):
pose_data_quality_overrides (specialist override at session_run or
session_exercise_event granularity; audit-logged)
DEFERRED (per DF2):
exercise_pose_validation_runs (telemetry-sourced; shape TBD)Class IIa provenance columns (applied to every association row)
tagged_by_principal_id UUID NOT NULL REFERENCES principals(id)
tagged_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
clinical_basis TEXT NULL (NOT NULL at Class IIa flip)Per-tag deprecation columns (applied to every tag entity)
deprecated_at TIMESTAMPTZ NULL
replaced_by_id UUID NULL (self-FK to same tag entity)Relationship sketch
exercises (1) ─┬─ (N) exercise_tags ─ (N→1) {body_regions, categories, equipment,
│ movement_patterns, recovery_phases,
│ conditions, skill_prerequisites}
│
├─ (N) exercise_instructions
├─ (N) exercise_contraindications ─ (N→1) exercise_conditions
├─ (N) exercise_prerequisites ─ (1→1) exercises (self)
│
└─ (1) exercise_pose_configs ─ (1→N) exercise_pose_config_history
├ (N) exercise_pose_landmarks ─ (N→1) pose_landmarks
├ (N) exercise_pose_metrics ─ (refs both
│ pose_landmarks via UUID[]
│ AND sibling metrics
│ via derived_from_metric_ids)
└ (N) exercise_pose_feedback_rules
session_runs ─ (N→1) pose_data_quality_overrides
session_exercise_events ─ (N→1) pose_data_quality_overridesIntegration plan (where this lands)
Doc folds
| Doc / Code | What folds in |
|---|---|
| data-model.md Area 9 | All schema: extended locked tables + new tag entities + per-tag deprecation columns + Class IIa provenance columns + full pose-tracking domain + pose_data_quality_overrides |
| decisions.md | New entries: "Why Class IIa columns on day one (D3)"; "Why per-tag deprecation over full vocabulary versioning (D4)"; "Why pose-tracking dedicated tables not JSONB (D6)"; "Why pose config pins to asset_version (D9)"; "Why locked vocabulary for body_regions/movement_patterns/recovery_phase/skill_prerequisites (D5)"; "Why platform-canonical conditions with optional ICD-10 (B5)" |
| data-classification.md | Registry entries for every new column (per CLAUDE.md requirement). New columns are ~60+ across all new tables — must be done in the same PR as the schema migration |
| glossary.md | New terms: pose engine, pose config, clinical basis, recovery phase, movement pattern, feedback rule, landmark subset, pose data quality override, skill prerequisite |
| features/exercise-library/index.md | Updated product spec: new axes (movement_pattern, recovery_phase, skill_prerequisites), conditions-as-lookup, pose-tracking authoring UX, asset-version invalidation flow, override workflow (deferred UI) |
| features/exercise-library/api.md | New endpoints: tag CRUD for new axes, pose config CRUD, override CRUD (API ships even if UI deferred), vocabulary listing endpoints |
| features/exercise-library/composition.md | Update Phase 2 backlog to reflect this design; reinforce that manifest stays clinical-metadata-free (D14) |
| telemetry/index.md | Close deferred decision at line 180 (dedicated tables resolved); add pose-config integration section (how engine reads configs); add pose_data_quality_overrides interaction with aggregator queries |
| implementation-plan/features.md F9.1 Phase 2 | Replace Phase 2 backlog with the build sequence below; update checkboxes accordingly |
| implementation-plan/foundation.md | Optional: note Class IIa columns as load-bearing for future MDR posture; cross-reference this doc |
Build sequencing (within F9.1 Phase 2)
All within F9.1 Phase 2 as one feature with ordered sub-phases. Each sub-phase ships independently.
Sub-phase A: Taxonomy schema + Class IIa columns
Migrations:
add_class_iia_columns_to_existing_taxonomy.up.sql—tagged_by_principal_id,tagged_at,clinical_basisonexercise_tags,exercise_contraindications,exercise_instructions(existing tables from locked design)add_per_tag_deprecation_columns.up.sql—deprecated_at,replaced_by_idonexercise_categories,exercise_body_regions,exercise_equipment+ DB trigger enforcing "never modify in place"lock_body_regions_to_platform.up.sql— CHECK constraintorganization_id IS NULLonexercise_body_regionsadd_new_taxonomy_axes.up.sql—exercise_movement_patterns,exercise_recovery_phases,exercise_skill_prerequisites(platform-only),exercise_conditions(dual-scope, withicd10_code); extendexercise_tags.tag_typeENUMadd_exercise_prerequisites.up.sql— self-M2M between exercisesmigrate_contraindications_to_condition_fk.up.sql— replaceexercise_contraindications.condition_namefreetext withcondition_idFK; backfill from existing data
Code:
- Go domain:
internal/core/domain/exercises_taxonomy/— repos + services + handlers for all new tag entities - API:
GET/POST/PATCH/DELETE /v1/admin/exercises/{id}/tags(covers all axes via tag_type), plus per-axis vocabulary endpointsGET /v1/exercises/vocabulary/{axis} - RBAC: new permission codes
catalog.tags.manage_platform(platform, superadmin),catalog.tags.manage_org(per-org, clinic admin) — dot notation matching existing convention (exercises.view_published,exercises.manage_org) - Egress: classification registry entries for all new columns (data-classification.md)
- SOUP: no new dependencies expected
- Audit: every tag CRUD audited per CLAUDE.md
- RLS: standard
organization_id IS NULL OR organization_id = current_app_org_id()policies on new tables - Backfill: clinical lead does a manual tagging pass on existing catalog (size manageable at F9.1 Phase 1's small dataset; main backfill happens at legacy migration window)
Console UI:
- F9.1 Phase 2 admin surfaces under existing 1D.2 Clinic admin tier (where applicable)
- Tag management UI (CRUD for all axes); vocabulary picker components
- Exercise edit form: tag chips, condition picker with ICD-10 lookup, prerequisite chains
- Body-map filtering for clinic-side exercise browsing
Downstream unblocks: stats consumers (pain map, cohort scatter, per-region filtering) light up as soon as taxonomy is populated. Patient-side pain map similarly.
Sub-phase B: Pose-tracking foundation (reference tables only)
Migrations:
add_pose_engines.up.sql—pose_enginesreference table, seeded withmediapipe.holisticadd_pose_landmarks.up.sql—pose_landmarksreference table, seeded with MediaPipe holistic catalog (~543 landmarks)
Code:
- Go domain:
internal/core/domain/pose_engines/— read-only repo + endpointsGET /v1/pose/engines,GET /v1/pose/engines/{id}/landmarks?body_part_category=... - Static seed data: maintained in
services/api/seed/pose_landmarks_mediapipe_holistic.jsonfor reproducible re-seeds - No RBAC additions (reference data, readable by all authenticated principals)
No Console UI at this sub-phase — these are reference tables consumed by Sub-phase C.
Sub-phase C: Pose-tracking per-exercise config
Migrations:
add_exercise_pose_configs.up.sql—exercise_pose_configs(1:1 with exercise per D8), with all D15/D16/D17/D20/B2 columns + Class IIa columns +pinned_asset_version+statusenumadd_exercise_pose_config_history.up.sql— immutable history table (full row snapshots)add_pose_config_per_config_detail.up.sql—exercise_pose_landmarks,exercise_pose_metrics,exercise_pose_feedback_rulestablesadd_pose_asset_version_invalidation_trigger.up.sql— DB trigger onexercises.asset_versionUPDATE that flips pose config status + reverts tracking_enabledadd_pose_config_clone_logic.up.sql— function for copy-on-clone (per D9 clone behavior)add_pose_data_quality_overrides.up.sql— override table per B3
Code:
- Go domain:
internal/core/domain/pose_configs/— repos + services + handlers - API: full pose config CRUD
GET/POST/PATCH /v1/admin/exercises/{id}/pose-config, plus sub-resource endpoints for landmarks/metrics/feedback rules; override endpointPOST /v1/admin/sessions/{run_id}/pose-override - App-layer validation: orphan landmark/metric prevention per B4 (block-removal)
- Validation:
weight_pctsum = 100 per config, JSONB params validated per rule type, condition_expression syntax (text_v1 = freetext for now) - RBAC: new permission codes
catalog.pose_configs.manage,catalog.pose_configs.invalidate,clinical.pose_overrides.create,clinical.pose_overrides.view— dot notation matching existing convention - Egress: classification registry entries for all new columns
- Audit: every pose config CRUD + every override audited
- RLS: standard tenant-scoping; override scope checked at app layer
Console UI:
- F9.1 Phase 2 pose-config authoring surface (Console under 1D.1 or similar):
- Camera setup section (D16)
- Landmark subset (D17) with body silhouette UI
- Metrics editor (D18) with weight-summing validation
- Feedback rules editor (D19, bare version)
- Rep success rule editor (D20)
- "Asset re-filmed — pose config invalidated" UI flow per D9
- Pose config history viewer (read-only, per D8)
Downstream unblocks: pose-tracking authoring goes from "support request" to self-service. Patient-side pose tracking unblocks once Sub-phase B + C are in place AND the pose-aggregation engine ships (separate F-tier work).
Backfill window
Coordinated with the legacy-platform data migration:
- Existing legacy catalog (~thousands of exercises from the legacy platform) gets tagged with body_regions, movement_patterns, recovery_phase, equipment, conditions, skill_prerequisites by the clinical lead during the migration window (NOT speculatively before — wait until the new schema is live and the migration tool can populate tags as part of the import)
- Pose configs left empty until clinical authoring pass post-migration (NOT a launch blocker — tracking_enabled defaults to FALSE)
What stays in scope vs deferred
In scope for F9.1 Phase 2 (this design):
- Sub-phases A + B + C above
- Console authoring UI for taxonomy + pose-config
- API surface for all of the above
- RBAC additions
- Classification registry entries
- Data-model.md + decisions.md + glossary.md + features/exercise-library/ doc updates
Deferred (per DF1–DF5):
- Condition expression DSL formalization (DF1) — depends on pose-aggregation engine (separate F-tier)
- Validation metrics shape (DF2) — depends on aggregator output
- Sofia AI auto-config (DF3) — no schema impact, build later if scoped
- Per-vocabulary org-private extension UI (DF4) — schema + API ship; only Console UI deferred
- Specialist signoff workflow (DF5) — defer everything including columns; Class IIa preparation work
Separate F-tier work (out of scope for F9.1 Phase 2):
- Pose-aggregation engine on telemetry side (the
POST /v1/pose/framesingest + aggregator pipeline) - Patient-side pose-tracking UX in Portal (consumes the configs we ship here; depends on engine)
- Program-builder integration with new taxonomy (separate F-tier; consumes tag data)
Coordination with other in-flight work
- 1D admin surfaces — taxonomy + pose-config admin UIs slot under 1D.1 (Console) and 1D.2 (Clinic admin) as new surface inventory entries. Coordinate route inventory updates in implementation-plan/1d-ui-inventory.md.
- Composer team — no coordination needed; manifest stays clinical-metadata-free per D14.
- Telemetry team — coordinate the close-out of telemetry/index.md:180 (dedicated table decision); set up the integration surface for when pose-aggregation engine ships.
- Clinical content team — backfill plan + ICD-10 mapping data + initial seed for
pose_landmarksbody_part_category translations.
Migration order safety
- Sub-phase A migrations must run before any new tag entity is written
- Sub-phase B (reference tables) must run before Sub-phase C (which FKs to them)
- Sub-phase C's
add_exercise_pose_configsmigration must run before the asset-version invalidation trigger (the trigger references columns from the configs table) - Backfill happens AFTER all migrations are applied
Open process question — RESOLVED
The build sequencing above places taxonomy + pose-tracking together as F9.1 Phase 2 sub-phases per the design session decision. implementation-plan/features.md gets updated accordingly when this design folds in.