Skip to content

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

http
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=25

Permission: exercises.view_published (all authenticated roles).

Query Parameters:

  • q (string) — Full text search on name/description (server-side, GIN trigram + unaccent per the production-scale convention)
  • category_id (uuid) — Filter by category
  • body_region_id (uuid) — Filter by body region
  • movement_pattern_id (uuid) — Filter by movement pattern
  • recovery_phase_id (uuid) — Filter by recovery phase
  • condition_id (uuid) — Filter by indicated condition
  • skill_prerequisite_id (uuid) — Filter by required skill prerequisite
  • equipment_id (uuid) — Filter by required equipment
  • difficulty (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 video
  • tracking_enabled (boolean) — Filter by pose-tracking active/inactive
  • Standard pagination: page, limit, sort. Multi-axis filters compose with AND semantics.

Response:

json
{
  "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

http
GET /v1/exercises/{id}

Response:

json
{
  "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)

http
POST /v1/exercises

Permission: exercises.manage_org (org admin) or RequireSuperadmin (platform-curated).

Request:

json
{
  "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")): sets organization_id = current_app_org_id()
  • Superadmin (gated by RequireSuperadmin) and no org context: sets organization_id = NULL (global)
  • Creates exercise_tags rows for provided taxonomy IDs across every axis (tag_type ENUM dispatches to the right table); tagged_by_principal_id and tagged_at are stamped from Subject.PrincipalID() per D3. Optional clinical_basis per association is accepted at the dedicated tag CRUD endpoints (see § Exercise Tag Associations).

Response: Exercise object (201 Created)

Update Exercise (Admin / Superadmin)

http
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)

http
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 RESTRICT FK on treatment_plan_session_exercises prevents actual deletion

Response: 204 No Content

Transition Exercise Status

http
PUT /v1/exercises/{id}/status

Request:

json
{
  "status": "published"
}

Allowed transitions:

  • draftpublished
  • publishedarchived
  • archivedpublished (re-publish)
  • archiveddraft (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

http
POST /v1/exercises/{id}/clone

Request:

json
{
  "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

http
POST /v1/exercises/{id}/video
Content-Type: multipart/form-data

Request:

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:

json
{
  "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

http
DELETE /v1/exercises/{id}/video

Backend:

  • Deletes video from CDN
  • Nullifies video_url, video_provider, video_thumbnail_url, video_duration_seconds

Response: 204 No Content


Exercise Instructions

List Instructions

http
GET /v1/exercises/{id}/instructions

Response:

json
{
  "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

http
POST /v1/exercises/{id}/instructions

Request:

json
{
  "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

http
PUT /v1/exercises/{id}/instructions/{instructionId}

Request: Same as add (partial updates allowed)

Response: Updated instruction object

Delete Instruction

http
DELETE /v1/exercises/{id}/instructions/{instructionId}

Response: 204 No Content

Upload Instruction Image

http
POST /v1/exercises/{id}/instructions/{instructionId}/image
Content-Type: multipart/form-data

Request:

file: [binary]

Response:

json
{
  "image_url": "https://cdn.example.com/exercises/550e8400.../instructions/1/image.jpg"
}

Reorder Instructions

http
PUT /v1/exercises/{id}/instructions/reorder

Request:

json
{
  "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

http
GET /v1/exercise-categories?scope=all

Query Parameters:

  • scope (string) — global, org, all (default)
  • parent_id (int) — Filter children of a specific parent

Response:

json
{
  "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)

http
POST /v1/exercise-categories

Request:

json
{
  "name": "Aquatic Therapy",
  "slug": "aquatic-therapy",
  "description": "Water-based rehabilitation exercises",
  "parent_id": null,
  "sort_order": 5
}

Response: Category object (201 Created)

Update Category

http
PUT /v1/exercise-categories/{id}

Response: Updated category object

Delete Category

http
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

http
GET /v1/exercise-body-regions?body_area=upper_body&scope=all

Query Parameters:

  • scope (string) — global, org, all (default)
  • body_area (string) — Filter by area (upper_body, lower_body, core, full_body)

Response:

json
{
  "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 D5RequireSuperadmin 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

http
GET /v1/exercise-equipment?scope=all

Response:

json
{
  "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

http
GET /v1/exercise-movement-patterns

Permission: any authenticated principal.

Response:

json
{
  "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

http
POST /v1/exercise-movement-patterns
PATCH /v1/exercise-movement-patterns/{id}
POST /v1/exercise-movement-patterns/{id}/deprecate

Permission: 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

http
GET /v1/exercise-recovery-phases

Returns: 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

http
GET /v1/exercise-conditions?q=lomb&icd10=M54&body_region_id={uuid}&scope=all&limit=25

Permission: any authenticated principal.

Query parameters:

  • q (string) — typeahead match on name / display_name_translations (GIN trigram + unaccent per the production-scale convention — Romanian "Stefan" matches "Ștefan")
  • icd10 (string) — exact or prefix match against icd10_code
  • body_region_id (uuid) — filter by clinical grouping
  • scopeglobal | org | all (default all)

Response:

json
{
  "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

http
POST /v1/exercise-conditions
PATCH /v1/exercise-conditions/{id}
POST /v1/exercise-conditions/{id}/deprecate

Permission: catalog.tags:write (platform rows, superadmin) or catalog.tags:write_private (org-private rows, org admin).

Request:

json
{
  "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

http
GET /v1/exercise-skill-prerequisites

Returns: 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

http
POST /v1/admin/exercises/{id}/tags

Permission: exercises.manage_org (org-scoped exercise) or RequireSuperadmin (platform exercise).

Request:

json
{
  "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

http
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

http
GET /v1/exercises/{id}/prerequisites

Permission: exercises.view_published.

Response:

json
{
  "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

http
POST /v1/admin/exercises/{id}/prerequisites

Permission: exercises.manage_org or RequireSuperadmin.

Request:

json
{
  "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

http
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

http
GET /v1/exercises/{id}/contraindications

Permission: exercises.view_published.

Response:

json
{
  "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

http
POST /v1/admin/exercises/{id}/contraindications

Permission: exercises.manage_org or RequireSuperadmin.

Request:

json
{
  "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

http
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

http
GET /v1/pose/engines

Permission: any authenticated principal.

Response:

json
{
  "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

http
GET /v1/pose/engines/{id}/landmarks?body_part_category=left_arm

Permission: any authenticated principal.

Query parameters:

  • body_part_category — optional filter (head | torso | left_arm | right_arm | left_leg | right_leg | hands | face)
  • include_deprecatedtrue to include deprecated landmarks (default: false — active only). Matches the platform-wide list-filter convention used by the taxonomy endpoints.

Response:

json
{
  "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

http
GET /v1/exercises/{slug}/pose-config

Permission: 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:

json
{
  "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

http
POST /v1/admin/exercises/{id}/pose-config

Permission: 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_pct across all metrics sums to 100 (app-layer)
  • rep_success_rule_params validated against rep_success_rule_type discriminator
  • Metric landmark_refs and derived_from_metric_ids must reference valid IDs (block-removal per B4)
  • pinned_asset_version defaults to the exercise's current asset_version on create

Patch Pose Config

http
PATCH /v1/admin/exercises/{id}/pose-config

Permission: 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

http
GET /v1/exercises/{slug}/pose-config/history

Permission: 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)

http
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:

json
{
  "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)

http
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:

json
{
  "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)

http
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:

json
{
  "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

http
POST /v1/admin/exercises/{id}/pose-config/invalidate

Permission: 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

http
POST /v1/admin/sessions/{run_id}/pose-override

Permission: clinical.pose_overrides:write (specialist + admin).

Request (session_run scope — entire session):

json
{
  "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):

json
{
  "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 EXISTS against this table

Response: Override object (201 Created).

List Overrides for a Session

http
GET /v1/admin/sessions/{run_id}/pose-overrides

Permission: 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.