Files
WerkoutAPI/hardening-report.md
Trey t c80c66c2e5 Codebase hardening: 102 fixes across 35+ files
Deep audit identified 106 findings; 102 fixed, 4 deferred. Covers 8 areas:

- Settings & deploy: env-gated DEBUG/SECRET_KEY, HTTPS headers, gunicorn, celery worker
- Auth (registered_user): password write_only, request.data fixes, transaction safety, proper HTTP status codes
- Workout app: IDOR protection, get_object_or_404, prefetch_related N+1 fixes, transaction.atomic
- Video/scripts: path traversal sanitization, HLS trigger guard, auth on cache wipe
- Models (exercise/equipment/muscle/superset): null-safe __str__, stable IDs, prefetch support
- Generator views: helper for registered_user lookup, logger.exception, bulk_update, transaction wrapping
- Generator core (rules/selector/generator): push-pull ratio, type affinity normalization, modality checks, side-pair exact match, word-boundary regex, equipment cache clearing
- Generator services (plan_builder/analyzer/normalizer): transaction.atomic, muscle cache, bulk_update, glutes classification fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:29:14 -06:00

26 KiB
Raw Permalink Blame History

Hardening Audit Report — Werkout API (Django/Python)

Audit Sources

  • 5 mapper agents (100% file coverage)
  • 8 specialized domain auditors (parallel)
  • 1 cross-cutting deep audit (parallel)
  • Total source files: 75

CRITICAL — Will crash or lose data (18 findings)

1. werkout_api/settings.py:16 | DEBUG=True hardcoded, never disabled in production

  • What: DEBUG = True set at module level. Production branch (when DATABASE_URL set) never overrides to False — the code is commented out (lines 142-157). CORS_ALLOW_ALL_ORIGINS on line 226 depends on DEBUG, so it's always True.
  • Impact: Full stack traces, SQL queries, internal paths exposed to end users. CORS allows any origin with credentials.
  • Source: Security, Config, Cross-cutting

2. werkout_api/settings.py:160 | SECRET_KEY falls back to 'secret'

  • What: SECRET_KEY = os.environ.get("SECRET_KEY", 'secret'). Neither docker-compose.yml nor any env file sets SECRET_KEY.
  • Impact: Session cookies, CSRF tokens, password hashes use a publicly known key. Complete auth bypass.
  • Source: Security, Config

3. werkout_api/settings.py:226 | CORS allows all origins with credentials in production

  • What: CORS_ALLOW_ALL_ORIGINS = True if DEBUG else False is always True (DEBUG never False). Combined with CORS_ALLOW_CREDENTIALS = True (line 231).
  • Impact: Any website can make authenticated cross-origin requests and steal data.
  • Source: Security, Config

4. registered_user/serializers.py:31 | Password hash exposed in API responses

  • What: write_only_fields = ('password',) is NOT a valid DRF Meta option. Correct: extra_kwargs = {'password': {'write_only': True}}. Password field is readable.
  • Impact: Hashed password returned in registration responses. Enables offline brute-force.
  • Source: Security, API, Cross-cutting

5. registered_user/views.py:83-90 | update_registered_user uses request.POST — JSON requests wipe user data

  • What: request.POST.get(...) only works for form-encoded data. JSON requests return None for all fields. Lines 88-90 set first_name=None, email=None, etc. and save.
  • Impact: Any JSON profile update silently corrupts user data. Email set to None breaks login.
  • Source: Security, Logic, Cross-cutting

6. registered_user/views.py:108-114 | Password update broken for JSON clients, can lock out user

  • What: request.POST.get("new_password") returns None for JSON. set_password(None) makes password uncheckable, permanently locking user out.
  • Impact: Password endpoint non-functional for JSON clients. Potential permanent account lockout.
  • Source: Security, Cross-cutting

7. registered_user/serializers.py:46 | Registration creates RegisteredUser with non-existent phone_number field

  • What: RegisteredUser.objects.create(phone_number=self.context.get("phone_number"), ...) — model has no phone_number field (removed in migration 0002).
  • Impact: User registration crashes with TypeError if phone_number is passed in context.
  • Source: Cross-cutting, Logic

8. scripts/views.py:43-45 | Anonymous cache wipe endpoint — no authentication

  • What: clear_redis view has no auth decorators. Active in scripts/urls.py. Any anonymous request wipes entire Redis cache.
  • Impact: Denial of service — any internet user can flush all cached data at will.
  • Source: Security

9. video/views.py:50-59 | Path traversal vulnerability in hls_videos

  • What: video_name and video_type from GET params concatenated directly into file paths without sanitization. ../../etc/passwd sequences can access arbitrary files.
  • Impact: Arbitrary file read on the server. Route commented out in urls.py but view exists.
  • Source: Security

10. video/views.py:74 | Celery task called with zero arguments but requires filename

  • What: create_hls_tasks.delay() called with no args. Task signature create_hls_tasks(filename) requires one.
  • Impact: Every call to /videos/create_hls/ crashes the Celery worker with TypeError.
  • Source: Celery, Cross-cutting

11. supervisord.conf:13 | Production runs Django dev server (runserver) instead of WSGI

  • What: python manage.py runserver 0.0.0.0:8000 in production. uwsgi.ini exists but is unused.
  • Impact: Single-threaded, no request timeouts, not designed for production. Memory leaks.
  • Source: Config

12. supervisord.conf | No Celery worker process configured

  • What: Only django and nextjs programs defined. No [program:celery] entry.
  • Impact: All .delay() calls queue tasks in Redis that are never consumed. Entire async task system non-functional.
  • Source: Celery, Config

13. supervisord.conf:13 | Auto-migrate on every container start

  • What: python manage.py migrate in startup command runs migrations automatically without review.
  • Impact: Destructive migrations run silently. Race conditions if multiple containers start simultaneously.
  • Source: Config

14. docker-compose.yml:8-10,26 | Database credentials hardcoded as postgres/postgres

  • What: POSTGRES_USER=postgres, POSTGRES_PASSWORD=postgres in compose file and DATABASE_URL. No .env override.
  • Impact: Trivial unauthorized access if database port exposed. Credentials in git history permanently.
  • Source: Security, Config

15. AI/workouts.py + AI/cho/workouts.py | 86K lines of PII data committed to git

  • What: Two files totaling 86,000+ lines of user workout data from Future Fitness API with user IDs, S3 URLs, timestamps.
  • Impact: PII permanently in git history. Potential GDPR/privacy liability.
  • Source: Security, Config

16. generator/views.py:1032-1160 | save_plan has no transaction wrapping

  • What: Creates GeneratedWeeklyPlan, then loops creating Workout, Superset, SupersetExercise, GeneratedWorkout, PlannedWorkout objects. No transaction.atomic().
  • Impact: Mid-loop failure (e.g., date parsing) leaves orphaned plan records. Partially saved plans with missing days.
  • Source: Data Integrity, Cross-cutting

17. generator/views.py:789-803 | confirm_plan has no transaction wrapping

  • What: Loops through generated workouts, saves status, deletes/creates PlannedWorkouts individually.
  • Impact: Partial plan confirmation — some days accepted, others not, on any mid-loop error.
  • Source: Data Integrity

18. registered_user/serializers.py:34-51 | User + RegisteredUser creation has no transaction

  • What: User.objects.create(), set_password(), RegisteredUser.objects.create(), Token.objects.create() — four DB ops with no transaction.atomic().
  • Impact: Orphaned User records if RegisteredUser creation fails. Ghost users block re-registration.
  • Source: Data Integrity, Cross-cutting

BUG — Incorrect behavior (28 findings)

1. registered_user/views.py:30,47 | Validation errors return HTTP 500 instead of 400

  • Impact: Clients can't distinguish server errors from bad input.

2. registered_user/views.py:74 | Failed login returns 404 instead of 401

  • Impact: Wrong HTTP semantics for auth failures.

3. registered_user/models.py:20 | __str__ concatenates nullable last_name — TypeError

  • Impact: Admin, logging crash for users with null last_name.

4. registered_user/admin.py:11 | Token.objects.get crashes if no token exists

  • Impact: Admin list page crashes if any user lacks a Token.

5. equipment/models.py:13 | __str__ concatenates nullable category/name — TypeError

  • Impact: Admin crashes for Equipment with null fields.

6. muscle/models.py:11 | __str__ returns None when name is null

  • Impact: Violates __str__ contract. Admin/template crashes.

7. workout/views.py:45 | Workout.objects.get with no DoesNotExist handling

  • Impact: Missing workouts return 500 instead of 404.

8. workout/views.py:60,143,165 | Validation errors return HTTP 500 instead of 400

  • Impact: Three views misreport client errors as server errors.

9. workout/views.py:69 | GET endpoint returns 201 Created instead of 200

  • Impact: Incorrect HTTP semantics for read operation.

10. workout/views.py:76 | Unreachable None check — .get() raises exception, never returns None

  • Impact: Dead code; actual DoesNotExist is unhandled (500 error).

11. workout/views.py:124 | estimated_rep_duration None multiplication crashes

  • What: exercise["reps"] * exercise_obj.estimated_rep_duration where field can be null. int * None = TypeError.
  • Impact: Workout creation crashes mid-loop, orphaning partial records (no transaction).

12. workout/serializers.py:37 | KeyError if 'notes' not in validated_data

  • Impact: Completing a workout without notes crashes with 500.

13. workout/serializers.py:40 | Wrong attribute name — health_kit UUID never persisted

  • What: Sets completed_workout.workout_uuid but model field is health_kit_workout_uuid.
  • Impact: HealthKit UUIDs silently discarded forever.

14. workout/tasks.py:85 | estimated_rep_duration None multiplication in Celery task

  • Impact: Bulk import crashes mid-way, leaving partial data.

15. workout/tasks.py:73 | Exercise.objects.get with no DoesNotExist handling

  • Impact: One missing exercise aborts entire import.

16. workout/urls.py:14 | Duplicate URL name 'plan workout' on two paths

  • Impact: reverse('plan workout') resolves to wrong URL.

17. scripts/views.py:37 | NameError: MuscleGroup is not defined

  • What: Catches MuscleGroup.DoesNotExist but only Muscle is imported.
  • Impact: NameError crashes endpoint instead of catching intended exception.

18. scripts/views.py:15 | equipment_required.split() crashes on None

  • Impact: sync_equipment crashes for any exercise with null equipment_required.

19. video/models.py:24 | save() missing *args in signature

  • Impact: Callers passing positional args (force_insert) get TypeError.

20. video/models.py:24-27 | HLS transcoding triggered on EVERY save, not just file changes

  • Impact: Redundant expensive ffmpeg jobs on metadata-only edits.

21. video/serializers.py:13 | video_file can be None — AttributeError

  • Impact: Video listing crashes if any Video has no file.

22. video/tasks.py:10 | Existence check uses wrong filename pattern — never matches

  • Impact: Guard clause never short-circuits; re-encodes every time.

23. generator/views.py:70 | RegisteredUser.objects.get repeated ~17 times with no DoesNotExist handling

  • Impact: Any user without RegisteredUser gets unhandled 500 on every generator endpoint.

24. superset/helpers.py:16 | Exercise.objects.get("First Up") with no error handling

  • Impact: Workout detail crashes if "First Up" exercise is missing.

25. superset/serializers.py:20 | get_unique_id returns random UUID per serialization

  • Impact: Frontend can't use unique_id as stable key. Breaks diffing/caching.

26. workout/models.py:51 | settings not imported — NameError on duration_audio()/weight_audio()

  • What: Relies on from exercise.models import * transitive import of settings.
  • Impact: NameError if transitive chain breaks.

27. workout_generator.py:909 | None multiplication when duration is None

  • Impact: Plan generation crashes if preferences have no duration set.

28. workout_generator.py:802 | sum(c.difficulty) crashes if any difficulty is None

  • Impact: Plan generation crashes for users with incomplete completion records.

SILENT FAILURE — Error swallowed or ignored (5 findings)

1. generator/views.py:193,491,874,989,1156 | Broad except Exception catches all errors, leaks str(e)

  • Impact: Bugs masked. Internal details leaked to clients.

2. superset/helpers.py:19-23 | In-memory mutations on Exercise ORM object never saved

  • Impact: Changes silently lost. Risk of corrupting shared Exercise if accidentally saved.

3. workout/helpers.py:41 | ser_data.mutable = True is a no-op

  • Impact: No effect. Indicates confusion about data type.

4. audit_exercise_data.py:168-170 | except Exception: pass silently swallows all errors

  • Impact: Database errors during field checks silently ignored.

5. workout/views.py:32 | Infinite cache with incomplete invalidation

  • Impact: Generated workouts never appear in all_workouts until manual cache clear.

RACE CONDITION — Concurrency issue (1 finding)

1. registered_user/views.py:34 | Email uniqueness check is a race condition

  • What: User.objects.filter(email=email) check followed by serializer.save(). No DB unique constraint visible.
  • Impact: Concurrent registrations can create duplicate email accounts.

LOGIC ERROR — Code doesn't match intent (12 findings)

1. rules_engine.py:650 | Push/pull ratio check skipped when either count is zero

  • What: Condition requires both counts > 0. A workout with 2 push, 0 pull passes silently.
  • Impact: Unbalanced push-heavy workouts pass validation.

2. rules_engine.py:858-860 | Workout type match is a no-op for non-strength types

  • What: Non-strength branch unconditionally counts every exercise as matching (100% always).
  • Impact: HIIT/cardio/core workouts can contain arbitrary exercises without violations.

3. workout_generator.py:1459 | Workout type affinity matching NEVER works

  • What: SPLIT_TYPE_WORKOUT_AFFINITY uses underscore names like 'traditional_strength_training' but comparison uses wt.name.strip().lower() which yields space-separated names.
  • Impact: All workout type assignments fall through to round-robin fallback. Push splits get assigned random types.

4. workout_generator.py:2070 | Modality check counts exercise capability, not actual assignment

  • What: Checks ex.is_duration (capability flag) not whether the entry was actually given duration.
  • Impact: False modality calculations for dual-modality exercises.

5. workout_generator.py:1404 | Diversify type count wrong on replacement

  • What: Doesn't subtract from the removed type count when replacing, only adds to candidate count.
  • Impact: Valid replacements rejected. Invalid ones accepted in edge cases.

6. workout_generator.py:2898 | Final conformance treats all warnings as blocking

  • What: _is_blocking_final_violation returns True for severity in {'error', 'warning'}.
  • Impact: Workouts crash with ValueError for minor advisory issues (cooldown missing, duration bias slightly off).

7. workout_generator.py:1209 | Recursive retry destroys cross-day dedup state

  • What: Failed attempt's exercises already recorded in week state via accumulate_week_state. Retry with different exercises creates ghost entries.
  • Impact: Later days in the week have artificially smaller exercise pools.

8. entry_rules.py:19 | Volume floor can violate workout type rep ranges

  • What: With min_volume=12 and rounds=1, forces 12 reps. Strength (3-6 rep range) gets 12 reps.
  • Impact: Strength workouts get inflated reps contradicting their character.

9. rules_engine.py:441 | Push/pull counting double-counts dual-pattern exercises

  • What: Exercise with 'upper push, upper pull' counted in BOTH push AND pull totals.
  • Impact: Inaccurate push:pull ratio calculations.

10. exercise_selector.py:631 | No-equipment path restricts to bodyweight only (contradicts docs)

  • What: MEMORY.md says "no equipment set = all exercises available." Code excludes all exercises with equipment entries.
  • Impact: Users without equipment config get dramatically reduced pool.

11. muscle_normalizer.py:163 | Glutes in both lower_push and lower_pull categories

  • Impact: Glute-dominant workouts get incorrect split classification, cascading into wrong type assignments.

12. exercise_selector.py:1274 | Substring partner matching causes false positives

  • What: if base_name.lower() in partner.name.lower() — "Curl" matches "Barbell Curl Right", "Hammer Curl Right", etc.
  • Impact: Wrong exercises paired as L/R counterparts.

PERFORMANCE — Unnecessary cost (18 findings)

1. exercise/serializers.py:30,35 | N+1 per exercise for muscles + equipment (~3400+ queries on cache miss)

  • Impact: /exercise/all/ cold cache: 1133 exercises × 3 queries each.

2. workout/serializers.py:56-77 | Triple N+1 on WorkoutSerializer (~5000+ queries)

  • Impact: all_workouts cache miss: 633 workouts × (muscles + equipment + exercise_count).

3. superset/serializers.py:32 | N+1 per superset for exercises, cascading through ExerciseSerializer

  • Impact: Each workout detail triggers O(supersets × exercises × 3) queries.

4. workout/helpers.py:14-71 | Cascade of N+1 queries in exercise list builder

  • Impact: ~80+ queries per workout detail (supersets + exercises + serializer chain).

5. generator/serializers.py:338 | N+1 for supersets in GeneratedWorkoutDetailSerializer

  • Impact: Plan detail views trigger dozens of cascading queries per day.

6. generator/views.py:1106 | Exercise.objects.get in triple-nested loop in save_plan

  • Impact: 5-day plan with 5 supersets × 3 exercises = 75 individual SELECT queries.

7. muscle_normalizer.py:218 | ExerciseMuscle query per exercise in analyzer (~19,000 queries)

  • Impact: analyze_workouts command fires ~19,000 queries for 633 workouts.

8. workout_analyzer.py:1332-1337 | 120 exists() checks in _step7

  • Impact: 8 types × 3 sections × 5 goals = 120 individual queries.

9. recalculate_workout_times.py:53-58 | Triple-nested N+1 with no prefetch (~18,000 queries)

  • Impact: Command takes orders of magnitude longer than necessary.

10. exercise_selector.py:593,629 | M2M querysets not cached (excluded_exercises + available_equipment)

  • Impact: 15-30 redundant identical queries per workout generation.

11. populate_exercise_fields.py:1006 | Individual save() per exercise (1133 UPDATE queries)

  • Impact: Command takes minutes instead of seconds. No bulk_update.

12. plan_builder.py:64,82 | Redundant save() after create() on Workout and Superset

  • Impact: 2 unnecessary DB writes per superset creation.

13. Various views | Infinite cache with no invalidation strategy

  • What: equipment, exercise, muscle, workout, video views all use cache.set(key, data, timeout=None) with no invalidation.
  • Impact: New/edited data never appears until manual cache clear or restart.

14. workout/serializers.py:109 | Redundant re-fetch of registered_user

  • Impact: Extra query per workout detail for no reason.

15. generator/views.py:570-572,604-607 | N+1 save pattern for re-ordering after delete

  • Impact: Up to N individual UPDATEs instead of 1 bulk_update.

16. generator/views.py:423-429,964-976 | N+1 for sibling exercise exclusion

  • Impact: N queries instead of 1 IN query for sibling workout exercises.

17. generator/views.py:70 | RegisteredUser.objects.get repeated 17x with no caching

  • Impact: 1 unnecessary query per API request across all generator endpoints.

18. exercise_selector.py:1063 | Potentially large retry loop in _weighted_pick

  • What: max_attempts = len(pool) * 3 with weighted pools of 500+ entries = 1500+ iterations.
  • Impact: CPU-bound stall risk in constrained pools.

SECURITY — Vulnerability or exposure (6 additional findings)

1. werkout_api/settings.py:140 | ALLOWED_HOSTS=['*'] in production

  • Impact: HTTP Host header injection, cache poisoning, password reset URL manipulation.

2. werkout_api/settings.py:1-231 | Missing all HTTPS/security hardening settings

  • What: No SECURE_SSL_REDIRECT, SECURE_HSTS_SECONDS, SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE, etc.
  • Impact: Cookies sent over plaintext HTTP. No HSTS protection.

3. werkout_api/settings.py:31 | Django Debug Toolbar enabled unconditionally

  • Impact: Exposes SQL queries, settings, request data at /__debug__/ in production.

4. workout/views.py:24-33 | all_workouts returns ALL users' workouts (IDOR)

  • What: Workout.objects.all() with no ownership filter.
  • Impact: Any authenticated user sees every user's workout data.

5. workout/views.py:39-49 | workout_details has no ownership check (IDOR)

  • What: Any authenticated user can view any workout by guessing IDs.
  • Impact: Insecure Direct Object Reference.

6. workout/views.py:170-172 | GET endpoint triggers data mutation — bulk import

  • What: GET triggers Celery task importing workouts for hardcoded user IDs. Any authenticated user can trigger.
  • Impact: Data corruption via idempotent-violating GET.

DATA INTEGRITY — Database/model consistency issues (5 findings)

1. workout/views.py:94-138 | add_workout has no transaction wrapping

  • Impact: Partial Workout/Superset records on mid-loop failure.

2. plan_builder.py:59-149 | create_workout_from_spec has no transaction wrapping

  • Impact: Core builder used by all generation paths creates orphaned records on error.

3. workout_analyzer.py:249-252 | _clear_existing_patterns deletes without transaction

  • Impact: If analysis crashes mid-way, ML pattern tables are empty with no recovery.

4. workout/tasks.py:11-101 | Bulk import has no transaction or idempotency

  • Impact: Partial imports, duplicate records on re-run.

5. workout/views.py:150 | datetime.now() without timezone in USE_TZ=True project

  • Impact: Incorrect PlannedWorkout filtering near midnight due to timezone mismatch.

MODERNIZATION — Legacy pattern to update (4 findings)

1. Dockerfile:13 | Python 3.9.13 base image (EOL October 2025)

  • Impact: No further security patches.

2. requirements.txt | All dependencies pinned to mid-2023 versions

  • Impact: Django 4.2.2 has had multiple security releases since.

3. supervisord.conf:24 | Next.js runs next dev in production

  • Impact: No production optimizations, source maps exposed.

4. Various models | max_length on IntegerField/FloatField (no-op parameters)

  • What: 10+ fields across superset, workout, exercise models use meaningless max_length on numeric fields.
  • Impact: Misleading — suggests validation that doesn't exist.

DEAD CODE / UNREACHABLE (4 findings)

1. exercise/serializers.py:5 | Import shadowed by local class definition

  • What: Imports ExerciseMuscleSerializer then redefines it locally.

2. exercise/models.py:4 | from random import randrange — imported but never used

3. audit_exercise_data.py:88-89 | Dead .exclude() clause — logically impossible condition

4. workout/views.py:76 | Unreachable None check after .get()


FRAGILE — Works now but will break easily (5 findings)

1. exercise_selector.py:613 | Hard exclude to soft penalty conversion mutates instance state permanently

  • What: _warned_small_pool guard uses hasattr which survives reset().
  • Impact: Once triggered, ALL subsequent selections treat hard-excluded exercises with soft penalty only.

2. exercise_selector.py:645 | Equipment map cache survives reset() — stale data possible

  • Impact: Low risk per-request but dangerous in long-running processes.

3. workout_generator.py:1046 | Working superset detection relies on name prefix 'Working'

  • Impact: Any naming inconsistency silently breaks trimming, padding, modality validation, compound ordering, rebalancing.

4. workout/models.py:51 | settings import via wildcard chain from exercise.models

  • Impact: Transitive dependency breaks if * re-export chain changes.

5. exercise_selector.py:260 | Working set exclusion icontains 'stretch' catches valid exercises

  • Impact: Exercises like "Stiff Leg Deadlift Stretch Position" incorrectly excluded from working sets.

Summary

Summary by Category

Category Count
Critical 18
Bug 28
Silent Failure 5
Race Condition 1
Logic Error 12
Performance 18
Security 6
Data Integrity 5
Modernization 4
Dead Code 4
Fragile 5
Total 106

Summary by Source

Source Findings
Security Auditor 34
Data Integrity/ORM Auditor 64
Logic Errors Auditor 42
Performance Auditor 41
Generator Logic Auditor 22
API Correctness Auditor 43
Celery/Async Auditor 24
Config/Deployment Auditor 30
Cross-cutting Deep Audit 35
(after dedup) 106 unique

Top 10 Priorities

  1. [CRITICAL] settings.py — DEBUG=True + SECRET_KEY='secret' + CORS wide open in production — Three compounding security misconfigurations that enable session forgery, CSRF bypass, and full API data theft from any website.

  2. [CRITICAL] registered_user/views.py:83-90 — request.POST wipes user data on JSON update — Any JSON profile update sets email, name, image all to None. Active, reachable endpoint.

  3. [CRITICAL] registered_user/serializers.py:31 — Password hash exposed in API — Invalid DRF Meta option means hashed password is readable in registration responses.

  4. [CRITICAL] scripts/views.py:43 — Anonymous cache wipe — Unauthenticated endpoint wipes entire Redis cache. Active route, no auth required.

  5. [CRITICAL] supervisord.conf — No Celery worker + dev server in production — All async tasks (HLS transcoding, imports) silently queue and never execute. Django dev server handles all production traffic.

  6. [CRITICAL] generator/views.py — No transaction.atomic() on save_plan/confirm_plan — Multi-object creation loops with no transaction wrapping leave orphaned records on any failure.

  7. [BUG] workout/serializers.py:40 — HealthKit UUID silently discarded — Sets wrong attribute name (workout_uuid vs health_kit_workout_uuid). Data permanently lost.

  8. [BUG] workout/views.py:124 + tasks.py:85 — None multiplication on estimated_rep_duration — Nullable field multiplied without null check. Crashes workout creation and bulk import.

  9. [LOGIC] workout_generator.py:1459 — Workout type affinity matching NEVER works — Space vs underscore comparison means all type assignments fall through to random round-robin.

  10. [PERFORMANCE] Serializer N+1 queries — 5000+ queries on cache miss — WorkoutSerializer, ExerciseSerializer, and SupersetSerializer each trigger per-object queries with no prefetching. Mitigated by infinite caching but devastating on any cache clear.