Exercise Library API Endpoints
F9.1 Phase 2 endpoint surface
The endpoint set below covers the F9.1 Phase 2 scope expansion (taxonomy + pose-tracking) — see exercise-taxonomy-pose-tracking.md → Integration plan for the authoritative endpoint list and data-model.md Area 9 for the underlying schema. Permission codes follow the design doc's catalog/clinical namespace; the exact resource.action mapping in migrations/core/ lands with the F9.1 Phase 2 migration.
Exercises
List Exercises
GET /v1/exercises?category_id={uuid}&body_region_id={uuid}&movement_pattern_id={uuid}&recovery_phase_id={uuid}&condition_id={uuid}&skill_prerequisite_id={uuid}&equipment_id={uuid}&difficulty=beginner&status=published&q=plank&scope=all&sort=-created_at&page=1&limit=25Permission: exercises.view_published (all authenticated roles).
Query Parameters:
q(string) — Full text search on name/description (server-side, GIN trigram +unaccentper the production-scale convention)category_id(uuid) — Filter by categorybody_region_id(uuid) — Filter by body regionmovement_pattern_id(uuid) — Filter by movement patternrecovery_phase_id(uuid) — Filter by recovery phasecondition_id(uuid) — Filter by indicated conditionskill_prerequisite_id(uuid) — Filter by required skill prerequisiteequipment_id(uuid) — Filter by required equipmentdifficulty(string) — Filter by difficulty (beginner | intermediate | advanced)status(string) — Filter by status (draft | published | archived)scope(string) —global(platform only),org(org only),all(default, both)has_video(boolean) — Filter exercises with/without videotracking_enabled(boolean) — Filter by pose-tracking active/inactive- Standard pagination:
page,limit,sort. Multi-axis filters compose withANDsemantics.
Response:
{
"exercises": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"organization_id": null,
"name": "Shoulder External Rotation",
"slug": "shoulder-external-rotation",
"description": "Strengthening exercise for the rotator cuff...",
"difficulty": "beginner",
"estimated_duration_seconds": 45,
"video_url": "https://cdn.example.com/exercises/shoulder-ext-rot.m3u8",
"video_provider": "bunny_stream",
"video_thumbnail_url": "https://cdn.example.com/exercises/shoulder-ext-rot-thumb.jpg",
"video_duration_seconds": 42,
"status": "published",
"cloned_from_id": null,
"categories": [
{"id": 2, "name": "Strengthening", "slug": "strengthening"}
],
"body_regions": [
{"id": 1, "name": "Shoulder", "slug": "shoulder", "body_area": "upper_body"}
],
"equipment": [
{"id": 3, "name": "Resistance Band", "slug": "resistance-band"}
],
"contraindications_count": 1,
"instructions_count": 4,
"created_at": "2026-01-15T10:00:00Z"
}
],
"total": 156,
"page": 1,
"limit": 25,
"total_pages": 7
}Get Exercise Details
GET /v1/exercises/{id}Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"organization_id": null,
"name": "Shoulder External Rotation",
"slug": "shoulder-external-rotation",
"description": "Strengthening exercise for the rotator cuff muscles...",
"instructions_summary": "Lie on side, rotate forearm upward keeping elbow pinned",
"difficulty": "beginner",
"estimated_duration_seconds": 45,
"video_url": "https://cdn.example.com/exercises/shoulder-ext-rot.m3u8",
"video_provider": "bunny_stream",
"video_thumbnail_url": "https://cdn.example.com/exercises/shoulder-ext-rot-thumb.jpg",
"video_duration_seconds": 42,
"status": "published",
"cloned_from_id": null,
"created_by_user_id": 1,
"categories": [
{"id": 2, "name": "Strengthening", "slug": "strengthening"}
],
"body_regions": [
{"id": 1, "name": "Shoulder", "slug": "shoulder", "body_area": "upper_body"}
],
"equipment": [
{"id": 3, "name": "Resistance Band", "slug": "resistance-band"}
],
"instructions": [
{
"id": 1,
"sort_order": 0,
"title": "Starting Position",
"content": "Lie on your side with a towel roll under your arm...",
"image_url": "https://...",
"instruction_type": "preparation"
},
{
"id": 2,
"sort_order": 1,
"title": "Movement",
"content": "Rotate your forearm upward, keeping elbow at 90 degrees...",
"image_url": null,
"instruction_type": "step"
},
{
"id": 3,
"sort_order": 2,
"title": null,
"content": "Keep your elbow pinned to your side throughout the movement",
"image_url": null,
"instruction_type": "form_cue"
},
{
"id": 4,
"sort_order": 3,
"title": null,
"content": "Stop if you feel sharp pain in the shoulder joint",
"image_url": null,
"instruction_type": "safety"
}
],
"movement_patterns": [{ "id": "...", "slug": "push", "name": "Push" }],
"recovery_phases": [{ "id": "...", "slug": "subacute", "name": "Subacute" }],
"conditions": [
{
"id": "...",
"slug": "rotator-cuff-impingement",
"name": "Rotator Cuff Impingement",
"icd10_code": "M75.4"
}
],
"skill_prerequisites": [{ "id": "...", "slug": "grip_strength", "name": "Grip Strength" }],
"prerequisites": [
{ "id": "...", "name": "Wall Slide", "slug": "wall-slide" }
],
"contraindications": [
{
"id": "...",
"condition": {
"id": "...",
"slug": "acute-shoulder-dislocation",
"name": "Acute Shoulder Dislocation",
"icd10_code": null
},
"description": "Do not perform within 6 weeks of shoulder dislocation",
"severity": "contraindicated",
"tagged_by_principal_id": "...",
"tagged_at": "2026-01-15T10:00:00Z",
"clinical_basis": null
}
],
"pose_config": {
"id": "...",
"tracking_enabled": true,
"engine_code": "mediapipe.holistic",
"status": "published",
"pinned_asset_version": 3
},
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-02-10T14:30:00Z"
}The full pose-config detail (landmark subset, metrics, feedback rules) lives at GET /v1/exercises/{slug}/pose-config — see § Pose Config. The summary above carries enough for catalog views without forcing a multi-row fetch.
Create Exercise (Admin / Superadmin)
POST /v1/exercisesPermission: exercises.manage_org (org admin) or RequireSuperadmin (platform-curated).
Request:
{
"name": "Shoulder External Rotation",
"slug": "shoulder-external-rotation",
"description": "Strengthening exercise for the rotator cuff muscles",
"instructions_summary": "Lie on side, rotate forearm upward keeping elbow pinned",
"difficulty": "beginner",
"estimated_duration_seconds": 45,
"status": "draft",
"category_ids": ["..."],
"body_region_ids": ["..."],
"movement_pattern_ids": ["..."],
"recovery_phase_ids": ["..."],
"condition_ids": ["..."],
"skill_prerequisite_ids": ["..."],
"equipment_ids": ["..."]
}Backend:
- Tenant caller (gated by
RequirePermission("exercises.manage_org")): setsorganization_id = current_app_org_id() - Superadmin (gated by
RequireSuperadmin) and no org context: setsorganization_id = NULL(global) - Creates
exercise_tagsrows for provided taxonomy IDs across every axis (tag_typeENUM dispatches to the right table);tagged_by_principal_idandtagged_atare stamped fromSubject.PrincipalID()per D3. Optionalclinical_basisper association is accepted at the dedicated tag CRUD endpoints (see § Exercise Tag Associations).
Response: Exercise object (201 Created)
Update Exercise (Admin / Superadmin)
PUT /v1/exercises/{id}Request: Same as create (partial updates allowed)
Validation:
- Admin can only update exercises where
organization_id = current_app_org_id() - Superadmin can update any exercise (including global)
Response: Updated exercise object
Soft Delete Exercise (Admin / Superadmin)
DELETE /v1/exercises/{id}Backend:
- Sets
deleted_at = NOW()(soft delete) - If exercise is referenced in active treatment plan sessions: returns 409 Conflict with message indicating the exercise is in use
- The
ON DELETE RESTRICTFK ontreatment_plan_session_exercisesprevents actual deletion
Response: 204 No Content
Transition Exercise Status
PUT /v1/exercises/{id}/statusRequest:
{
"status": "published"
}Allowed transitions:
draft→publishedpublished→archivedarchived→published(re-publish)archived→draft(back to editing)
Validation:
- Publishing requires: name, at least one instruction step
- Video is recommended but not required for publishing
Response: Updated exercise object
Clone Exercise
POST /v1/exercises/{id}/cloneRequest:
{
"name": "Custom Shoulder Rotation (Modified)",
"slug": "custom-shoulder-rotation-modified"
}Backend:
- Copies all exercise fields (except id, slug, status, organization_id)
- Copies all instructions (with images)
- Copies all contraindications
- Copies all tags
- Sets
organization_id = current_app_org_id() - Sets
cloned_from_id = source exercise ID - Sets
status = 'draft' - Uses provided name/slug or auto-generates from source
Response: New exercise object (201 Created)
Exercise Video
Upload Video
POST /v1/exercises/{id}/video
Content-Type: multipart/form-dataRequest:
file: [binary]Backend:
- Uploads to CDN (Bunny Stream or S3)
- For Bunny Stream: triggers automatic HLS transcoding
- Updates
exercises.video_url,video_provider,video_duration_seconds - Auto-generates thumbnail if CDN supports it, updates
video_thumbnail_url
Response:
{
"video_url": "https://cdn.example.com/exercises/550e8400.../video.m3u8",
"video_provider": "bunny_stream",
"video_thumbnail_url": "https://cdn.example.com/exercises/550e8400.../thumb.jpg",
"video_duration_seconds": 42
}Remove Video
DELETE /v1/exercises/{id}/videoBackend:
- Deletes video from CDN
- Nullifies
video_url,video_provider,video_thumbnail_url,video_duration_seconds
Response: 204 No Content
Exercise Instructions
List Instructions
GET /v1/exercises/{id}/instructionsResponse:
{
"instructions": [
{
"id": 1,
"sort_order": 0,
"title": "Starting Position",
"content": "Lie on your side...",
"image_url": null,
"instruction_type": "preparation",
"created_at": "2026-01-15T10:00:00Z"
}
]
}Add Instruction Step
POST /v1/exercises/{id}/instructionsRequest:
{
"sort_order": 1,
"title": "Movement",
"content": "Rotate your forearm upward, keeping elbow at 90 degrees",
"instruction_type": "step"
}Response: Instruction object (201 Created)
Update Instruction
PUT /v1/exercises/{id}/instructions/{instructionId}Request: Same as add (partial updates allowed)
Response: Updated instruction object
Delete Instruction
DELETE /v1/exercises/{id}/instructions/{instructionId}Response: 204 No Content
Upload Instruction Image
POST /v1/exercises/{id}/instructions/{instructionId}/image
Content-Type: multipart/form-dataRequest:
file: [binary]Response:
{
"image_url": "https://cdn.example.com/exercises/550e8400.../instructions/1/image.jpg"
}Reorder Instructions
PUT /v1/exercises/{id}/instructions/reorderRequest:
{
"instruction_ids": [3, 1, 2, 4]
}Backend: Updates sort_order for each instruction based on array position.
Response: Updated instructions list
Exercise Categories
List Categories
GET /v1/exercise-categories?scope=allQuery Parameters:
scope(string) —global,org,all(default)parent_id(int) — Filter children of a specific parent
Response:
{
"categories": [
{
"id": 1,
"organization_id": null,
"name": "Stretching",
"slug": "stretching",
"description": "Flexibility and range-of-motion exercises",
"parent_id": null,
"sort_order": 0,
"children": [
{"id": 4, "name": "Static Stretching", "slug": "static-stretching", "parent_id": 1},
{"id": 5, "name": "Dynamic Stretching", "slug": "dynamic-stretching", "parent_id": 1}
]
},
{
"id": 2,
"organization_id": null,
"name": "Strengthening",
"slug": "strengthening",
"description": "Muscle strengthening exercises",
"parent_id": null,
"sort_order": 1,
"children": []
}
]
}Create Category (Admin / Superadmin)
POST /v1/exercise-categoriesRequest:
{
"name": "Aquatic Therapy",
"slug": "aquatic-therapy",
"description": "Water-based rehabilitation exercises",
"parent_id": null,
"sort_order": 5
}Response: Category object (201 Created)
Update Category
PUT /v1/exercise-categories/{id}Response: Updated category object
Delete Category
DELETE /v1/exercise-categories/{id}Backend:
- Children get
parent_id = NULL(become top-level) - Exercise tags referencing this category are removed
Response: 204 No Content
Exercise Body Regions
List Body Regions
GET /v1/exercise-body-regions?body_area=upper_body&scope=allQuery Parameters:
scope(string) —global,org,all(default)body_area(string) — Filter by area (upper_body, lower_body, core, full_body)
Response:
{
"body_regions": [
{
"id": 1,
"organization_id": null,
"name": "Shoulder",
"slug": "shoulder",
"body_area": "upper_body",
"sort_order": 0
}
]
}Create / Update / Delete
Same CRUD pattern as categories. Platform-only per D5 — RequireSuperadmin required; an org-scoped caller gets 403. The CHECK constraint organization_id IS NULL makes the lock structural at the DB layer.
Permission: catalog.tags:write (platform superadmin).
Exercise Equipment
List Equipment
GET /v1/exercise-equipment?scope=allResponse:
{
"equipment": [
{
"id": 1,
"organization_id": null,
"name": "No Equipment",
"slug": "no-equipment",
"icon_url": null,
"sort_order": 0
},
{
"id": 2,
"organization_id": null,
"name": "Resistance Band",
"slug": "resistance-band",
"icon_url": "https://...",
"sort_order": 1
}
]
}Create / Update / Delete
Same CRUD pattern as categories — dual-scope per D5. RequireSuperadmin for platform rows; RequirePermission("catalog.tags:write_private") for org-private rows.
Exercise Movement Patterns
Platform-only per D5. Pose-engine rep-counting heuristics map to these values.
List Movement Patterns
GET /v1/exercise-movement-patternsPermission: any authenticated principal.
Response:
{
"movement_patterns": [
{
"id": "...",
"slug": "squat",
"name": "Squat",
"description": "Bilateral lower-body flexion-extension",
"sort_order": 3,
"deprecated_at": null,
"replaced_by_id": null,
"translations": { "ro": { "name": "Genuflexiune" } }
}
]
}Create / Update / Deprecate
POST /v1/exercise-movement-patterns
PATCH /v1/exercise-movement-patterns/{id}
POST /v1/exercise-movement-patterns/{id}/deprecatePermission: catalog.tags:write (platform superadmin).
Per D4, tag rows are never modified in place — a DB trigger rejects canonical-field UPDATEs. To rename, deprecate the old row and create a new one with the replacement; POST .../{id}/deprecate sets deprecated_at = NOW() and optionally replaced_by_id.
Exercise Recovery Phases
Platform-only per D5.
List Recovery Phases
GET /v1/exercise-recovery-phasesReturns: acute | subacute | strength | return_to_activity | maintenance with translations + sort order. Same shape as movement patterns.
Create / Update / Deprecate
Same pattern as movement patterns. Permission: catalog.tags:write (platform superadmin).
Exercise Conditions
Dual-scope per D5. Platform-canonical names + optional ICD-10 mapping per B5.
List / Search Conditions
GET /v1/exercise-conditions?q=lomb&icd10=M54&body_region_id={uuid}&scope=all&limit=25Permission: any authenticated principal.
Query parameters:
q(string) — typeahead match onname/display_name_translations(GIN trigram +unaccentper the production-scale convention — Romanian "Stefan" matches "Ștefan")icd10(string) — exact or prefix match againsticd10_codebody_region_id(uuid) — filter by clinical groupingscope—global | org | all(defaultall)
Response:
{
"conditions": [
{
"id": "...",
"organization_id": null,
"slug": "lombalgie",
"name": "Lombalgie",
"description": "Low back pain",
"icd10_code": "M54.5",
"body_region_id": "...",
"status": "active",
"translations": { "en": { "name": "Low back pain" } }
}
]
}Use this endpoint to back the contraindication picker UI (typeahead by name; ICD-10 metadata visible alongside).
Create / Update / Deprecate Condition
POST /v1/exercise-conditions
PATCH /v1/exercise-conditions/{id}
POST /v1/exercise-conditions/{id}/deprecatePermission: catalog.tags:write (platform rows, superadmin) or catalog.tags:write_private (org-private rows, org admin).
Request:
{
"slug": "post-acl-reconstruction",
"name": "Post-ACL Reconstruction",
"description": "Following anterior cruciate ligament repair",
"icd10_code": "Z98.89",
"body_region_id": "...",
"translations": { "en": { "name": "Post-ACL Reconstruction" } }
}icd10_code is nullable — clinicians work with names; the code rides along for EHR/insurance interop where known.
Exercise Skill Prerequisites
Platform-only per D5.
List Skill Prerequisites
GET /v1/exercise-skill-prerequisitesReturns: balance_static | balance_dynamic | single_leg_stance | floor_to_stand | grip_strength | bilateral_coordination | weight_bearing_tolerance | core_endurance. Same shape as movement patterns.
Create / Update / Deprecate
Same pattern as movement patterns. Permission: catalog.tags:write (platform superadmin).
Exercise Tag Associations
Per-exercise tag CRUD across every axis. Backed by the polymorphic exercise_tags junction (tag_type ENUM dispatches to the right table per D2).
Add Tag
POST /v1/admin/exercises/{id}/tagsPermission: exercises.manage_org (org-scoped exercise) or RequireSuperadmin (platform exercise).
Request:
{
"tag_type": "movement_pattern",
"tag_id": "...",
"clinical_basis": "Primary movement is hip hinge with bilateral loading"
}tag_type is one of category | body_region | equipment | movement_pattern | recovery_phase | condition | skill_prerequisite. tag_id resolves against the table named by tag_type. clinical_basis is nullable today and flips to NOT NULL at the Class IIa elevation per D3; the Console authoring UI surfaces it as an optional one-line input now and a required field on Class IIa flip.
Backend: stamps tagged_by_principal_id = Subject.PrincipalID() and tagged_at = NOW(). Audit-logged.
Remove Tag
DELETE /v1/admin/exercises/{id}/tags?tag_type={type}&tag_id={uuid}Response: 204 No Content.
Exercise Prerequisites (Exercise → Exercise Chain)
Self-M:M between exercises per D2. "Bird Dog before Side Plank" — program-builder ordering.
List Prerequisites for an Exercise
GET /v1/exercises/{id}/prerequisitesPermission: exercises.view_published.
Response:
{
"prerequisites": [
{
"exercise_id": "...",
"name": "Bird Dog",
"slug": "bird-dog",
"tagged_by_principal_id": "...",
"tagged_at": "2026-05-25T10:00:00Z",
"clinical_basis": "Establishes contralateral core stability before progression"
}
]
}Add Prerequisite
POST /v1/admin/exercises/{id}/prerequisitesPermission: exercises.manage_org or RequireSuperadmin.
Request:
{
"prerequisite_exercise_id": "...",
"clinical_basis": "Establishes contralateral core stability before progression"
}Backend: prevents self-FK (exercise_id != prerequisite_exercise_id) and cycle detection (app-side — a DB trigger could enforce but is overkill for the size of expected chains).
Remove Prerequisite
DELETE /v1/admin/exercises/{id}/prerequisites/{prerequisite_exercise_id}Response: 204 No Content.
Exercise Contraindications
Replaces the historical freetext-condition_name shape with condition_id FK to exercise_conditions per B5.
List Contraindications
GET /v1/exercises/{id}/contraindicationsPermission: exercises.view_published.
Response:
{
"contraindications": [
{
"id": "...",
"condition": {
"id": "...",
"slug": "acute-shoulder-dislocation",
"name": "Acute Shoulder Dislocation",
"icd10_code": null
},
"description": "Do not perform within 6 weeks of shoulder dislocation",
"severity": "contraindicated",
"tagged_by_principal_id": "...",
"tagged_at": "2026-01-15T10:00:00Z",
"clinical_basis": null
}
]
}Add Contraindication
POST /v1/admin/exercises/{id}/contraindicationsPermission: exercises.manage_org or RequireSuperadmin.
Request:
{
"condition_id": "...",
"description": "Do not perform within 6 weeks of shoulder dislocation",
"severity": "contraindicated",
"clinical_basis": "Mechanical stress on healing capsular tissue"
}condition_id must reference a row in exercise_conditions (use the conditions search endpoint to populate the picker).
Update / Delete
PATCH /v1/admin/exercises/{id}/contraindications/{contraindicationId}
DELETE /v1/admin/exercises/{id}/contraindications/{contraindicationId}Pose Engines (Reference)
Read-only reference catalog of available pose-detection engines. F9.1 Phase 2 seeds with mediapipe.holistic only.
List Pose Engines
GET /v1/pose/enginesPermission: any authenticated principal.
Response:
{
"engines": [
{
"id": "...",
"code": "mediapipe.holistic",
"display_name": "MediaPipe Holistic",
"vendor": "Google MediaPipe",
"version": "0.10.x",
"landmark_catalog_version": 1,
"status": "active"
}
]
}List Landmarks for an Engine
GET /v1/pose/engines/{id}/landmarks?body_part_category=left_armPermission: any authenticated principal.
Query parameters:
body_part_category— optional filter (head | torso | left_arm | right_arm | left_leg | right_leg | hands | face)include_deprecated—trueto include deprecated landmarks (default:false— active only). Matches the platform-wide list-filter convention used by the taxonomy endpoints.
Response:
{
"landmarks": [
{
"id": "...",
"engine_id": "...",
"code": "left.shoulder",
"display_name": "Left Shoulder",
"display_name_translations": { "ro": "Umăr stâng" },
"body_part_category": "left_arm",
"status": "active"
}
],
"total": 33
}Use this endpoint to back the landmark-picker UI on the pose-config authoring surface. The full MediaPipe holistic catalog (~543 entries) is paginated server-side; the picker filters by body_part_category for usability.
Pose Config
Per-exercise pose-tracking configuration. 1:1 with exercises per D8.
Get Pose Config
GET /v1/exercises/{slug}/pose-configPermission: catalog.pose_configs.manage (gates read visibility at the route layer; RLS handles per-org scoping).
The consumer-facing detail read sits under /v1/ (not /v1/admin/) so the response is cache-tagged under platform:pose-configs:exercise:{exercise_id} for the Next.js layer (P42). Writes + admin reads (history) live under /v1/admin/ — see below.
Response:
{
"id": "...",
"exercise_id": "...",
"tracking_enabled": true,
"engine_id": "...",
"engine_code": "mediapipe.holistic",
"camera_angle": "lateral",
"camera_distance_cm_min": 200,
"camera_distance_cm_max": 350,
"lighting_requirement": "ambient",
"in_frame_requirements": ["fata_integral_vizibila", "umeri_in_cadru"],
"rep_success_rule_type": "angle_cycle",
"rep_success_rule_params": {
"side": "both",
"target_angle_min": 60,
"target_angle_max": 80,
"return_angle": 0,
"return_tolerance": 5
},
"pinned_asset_version": 3,
"min_landmark_confidence": 0.5,
"status": "published",
"landmarks": [
{ "id": "...", "landmark_id": "...", "code": "left.shoulder", "clinical_basis": null }
],
"metrics": [
{
"id": "...",
"metric_type": "angle",
"label": "Knee flexion",
"target_min": 60,
"target_max": 80,
"tolerance": 5,
"weight_pct": 40,
"landmark_refs": ["..."],
"derived_from_metric_ids": null,
"axis": "z",
"clinical_basis": null
}
],
"feedback_rules": [
{
"id": "...",
"severity": "warning",
"condition_expression": "knee_angle < 30",
"condition_format": "text_v1",
"patient_message": "Bend your knee deeper",
"patient_message_translations": { "ro": "Îndoaie genunchiul mai mult" }
}
],
"tagged_by_principal_id": "...",
"tagged_at": "2026-05-25T10:00:00Z",
"clinical_basis": null,
"created_at": "2026-05-25T10:00:00Z",
"updated_at": "2026-05-25T10:00:00Z"
}Returns 404 if no pose config exists for the exercise.
Create or Replace Pose Config
POST /v1/admin/exercises/{id}/pose-configPermission: catalog.pose_configs:write.
Request: the same shape as the GET response (minus id, timestamps, and Class IIa stamps). Server creates the row + a created history snapshot. If a config already exists, the call replaces it (the prior version is preserved as an updated history snapshot).
Validation:
weight_pctacross all metrics sums to 100 (app-layer)rep_success_rule_paramsvalidated againstrep_success_rule_typediscriminator- Metric
landmark_refsandderived_from_metric_idsmust reference valid IDs (block-removal per B4) pinned_asset_versiondefaults to the exercise's currentasset_versionon create
Patch Pose Config
PATCH /v1/admin/exercises/{id}/pose-configPermission: catalog.pose_configs:write.
Partial updates to the top-level row fields (camera setup, tracking_enabled, status). Sub-resource edits (landmarks, metrics, feedback rules) go through the dedicated endpoints below. Every PATCH writes an updated snapshot to exercise_pose_config_history.
Pose Config History
GET /v1/exercises/{slug}/pose-config/historyPermission: catalog.pose_configs.manage.
Returns the full append-only snapshot stream (paginated, newest-first). Each row carries the full snapshot of exercise_pose_configs at that point in time + a snapshot_reason (edit | invalidated_by_asset_version | archived | published | cloned). Required for Class IIa scoring reproducibility per D8.
Pose Config Landmarks (Sub-Resource)
POST /v1/admin/exercises/{id}/pose-config/landmarks
DELETE /v1/admin/exercises/{id}/pose-config/landmarks/{landmark_id}Permission: catalog.pose_configs:write.
Add request:
{
"landmark_id": "...",
"clinical_basis": "Tracks scapulothoracic excursion"
}Block-removal per B4: DELETE fails with a clear error if any metric's landmark_refs references the landmark — drop the dependent metric first.
Pose Config Metrics (Sub-Resource)
POST /v1/admin/exercises/{id}/pose-config/metrics
PATCH /v1/admin/exercises/{id}/pose-config/metrics/{metric_id}
DELETE /v1/admin/exercises/{id}/pose-config/metrics/{metric_id}Permission: catalog.pose_configs:write.
Add request:
{
"metric_type": "angle",
"label": "Knee flexion",
"label_translations": { "ro": "Flexie genunchi" },
"target_min": 60,
"target_max": 80,
"tolerance": 5,
"weight_pct": 40,
"landmark_refs": ["..."],
"derived_from_metric_ids": null,
"axis": "z",
"clinical_basis": "Primary measure of squat depth"
}weight_pct is validated app-side to sum to 100 across all metrics per config. DELETE blocks if another metric references this one via derived_from_metric_ids.
Pose Config Feedback Rules (Sub-Resource)
POST /v1/admin/exercises/{id}/pose-config/feedback-rules
PATCH /v1/admin/exercises/{id}/pose-config/feedback-rules/{rule_id}
DELETE /v1/admin/exercises/{id}/pose-config/feedback-rules/{rule_id}Permission: catalog.pose_configs:write.
Add request:
{
"severity": "critical",
"condition_expression": "knee_valgus > 15",
"condition_format": "text_v1",
"patient_message": "Knee tracking inward — pause and reset stance",
"patient_message_translations": { "ro": "Genunchiul cade în interior — oprește și repoziționează" },
"clinical_basis": "Valgus collapse increases ACL load"
}Bare schema per D10: no rate-limiting columns at F9.1 Phase 2. condition_format defaults to 'text_v1' (the parser DSL is deferred per DF1).
Invalidate Pose Config
POST /v1/admin/exercises/{id}/pose-config/invalidatePermission: catalog.pose_configs:invalidate.
Sets status = 'invalidated' and tracking_enabled = FALSE. Triggered manually by an admin OR automatically by the exercises.asset_version DB trigger when the exercise is re-filmed (per D9). Writes an invalidated_by_asset_version (auto) or status_changed (manual) snapshot to history.
Auto-invalidation event surface
The platform does NOT ship a dedicated webhook or status-query endpoint for asset-version invalidation events at F9.1 Phase 2 — the Console pose-config authoring surface polls GET /v1/admin/exercises/{id}/pose-config and reads status directly. If a third-party automation use case appears, an events.Bus event (exercise.pose_config_invalidated) can be added under Cat E without schema changes. See composition.md for the broader asset_version bump workflow.
Pose Data Quality Override
Specialist clinical judgment that a session's pose data is unreliable and must be excluded from scoring. Per B3.
Create Override
POST /v1/admin/sessions/{run_id}/pose-overridePermission: clinical.pose_overrides:write (specialist + admin).
Request (session_run scope — entire session):
{
"scope": "session_run",
"override_reason": "Camera angle drifted during set 2; tracking confidence below threshold"
}Request (session_exercise_event scope — one exercise within the session):
{
"scope": "session_exercise_event",
"session_exercise_event_id": "...",
"override_reason": "Patient adjusted camera mid-set; tracking confidence dropped"
}Backend:
- CHECK constraint enforces
(scope = 'session_run' AND session_exercise_event_id IS NULL) OR (scope = 'session_exercise_event' AND session_exercise_event_id IS NOT NULL) - Audit-logged with full
overridden_by_principal_id+override_reason(Class IIa requirement) - Stats queries / promotion threshold excludes overridden data via
WHERE NOT EXISTSagainst this table
Response: Override object (201 Created).
List Overrides for a Session
GET /v1/admin/sessions/{run_id}/pose-overridesPermission: clinical.pose_overrides:read (specialist + admin + customer support).
Delete Override — not implemented at F9.1 Phase 2
Per B3, pose_data_quality_overrides is an append-only table (the migration's REVOKE blocks UPDATE/DELETE from the AppPool); a clinical "I filed this in error" workflow would need either a SECURITY DEFINER undelete helper or a soft-restore flag column. Neither ships at F9.1 Phase 2 — queued as future work in the design doc's follow-up list. Specialists file a corrective override of opposite intent for now.
Console UI for overrides deferred
Per B3, the API ships at F9.1 Phase 2; the Console specialist-facing UI for invoking overrides is deferred. Initial workflow = support request to platform team. Build trigger: first clinic explicit feature request, OR a specialist workflow demand pattern.