Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesWorkout ParseWorkout Parse — Behavior

Workout Parse — Behavior

Surface

EndpointPurpose
POST /organizations/:orgId/workouts/parseParse pasted text → 201 { data: ParseDraft }. Synchronous; one Sonnet call, ~5–15s.
GET /organizations/:orgId/workouts/parse/:jobIdRe-fetch a draft (org-scoped) → 200 { data: ParseDraft & { status } }. Preview refresh / ?jobId= deep link.
POST /organizations/:orgId/workouts/parse/:jobId/commitCommit the adjusted draft → 201 { data: { workoutId, delta } }.
POST /organizations/:orgId/workouts/parse/:jobId/discardDiscard → 200 { data: { ok: true } }. Job can no longer be committed.

All routes: global AuthGuard + org membership + staff role + @RequiresFeature('workout_builder'). Controller: apps/api/src/ai/parse/parse.controller.ts.

Gate order (POST /parse)

Each gate maps to a distinct error code (libs/shared/src/lib/parse-schemas/parse-api.schema.ts):

  1. Auth + requireMembership; role must be owner/admin/coach → else 403 (plain Forbidden).
  2. @RequiresFeature('workout_builder') tier guard → else 403 (plain tier error; no parse.blocked event — the guard runs before the handler).
  3. Length ≤ PARSE_MAX_INPUT_CHARS (10,000) → else 422 parse_too_long.
  4. Budget AgentRateLimitService.preCheck(orgId) (monthly backstop checked first, then daily) → on breach 429 ai_budget_exceeded with { period: 'day' | 'month' }.

Commit/discard re-check membership + role + tier but not budget (no LLM call on those paths).

Structured fidelity mode (TKN-355)

All new parses use structured fidelity; the existing parse entry-point and tier gates still apply. New drafts store fidelityMode: true as a format marker. Previously saved drafts without that marker retain their compatible review and commit path.

  • Explicit multi-session source is rejected before the model call, and the model’s multi-session result is rejected rather than sliced to session one. Commit rechecks the stored source and draft, including older drafts. A day may contain multiple sessions, but this endpoint accepts one session; it does not replace the upstream Notion/day splitter. Decorative separators such as <<>> do not establish session boundaries: warm-up, lift, strength, conditioning and accessory blocks on one class timeline remain one workout. The deterministic guard requires explicit session/day/date headers; extraction also evaluates session semantics.
  • English programming with Hebrew or Russian guidance is accepted. Workout-level guidance becomes coachNote with paragraph boundaries. Operative section instructions remain in the section description.
  • Source claims are repaired only against literal source occurrences. Section ownership extends through trailing instructions to the next block; whitespace and decorative separators are not unparsed instructions. Titles must occur in the source, and identical movement coaching cues are not rendered twice.
  • Sequential into blocks retain order. Explicit + complexes retain their component order and visible grouping text without inventing supersets.
  • Standalone rounds/sets, including 2-3 sets, become canonical repeat counts. Untimed repeats use rounds.forTime: false; absent forTime retains legacy timed-round behavior. Section-local RPE bands, tempo, percentage ranges, alternation and optionality are recovered from source without duplicating section counts on each movement. A prescription-only continuation is attached to a unique literal movement name, never emitted as a second exercise.
  • Explicit total-repetition scoring applies to the workout, not its warm-up. Conditional equipment such as an optional vest and sex-labelled box heights stays visible prose; it is not promoted to a mandatory movement load.
  • A unique canonical-name match can resolve automatically. A one-word alias, normalized search score alone, or a semantic rewrite cannot silently choose an exercise. Suggested candidates are not preselected in fidelity mode.
  • Match quality distinguishes automatic matches, explicit confirmations and movements preserved as notes. Preserving a movement does not count as matching it. Candidate search scores rank results, not confidence; the review popover does not label score-only suggestions as strong matches.
  • Every uncertain movement must be picked, created or explicitly preserved as instructions. Save/publish is blocked until that review is complete. The commit endpoint validates review choices against the stored draft, rejects omitted auto movements and missing preserved source, and requires explicit preservation of unconverted instructions. Multi-line instructions are verified line by line, including repeated source occurrences. Repeating preservation does not duplicate an instruction block.
  • Structural issues are separate from exercise matching and preservable prose. A shared clock with periodic interruptions and remaining-time work is not representable by the current flat shapes. structuralIssues blocks save, publish and builder adjustment even after every other review is acknowledged. Commit rechecks stored source, including drafts without that field, and returns parse_review_required (409). No interrupted-clock model is inferred.
  • Declarators the sections already carry are not sent to review or flagged structural: a rep scheme (and rounds marked for time) is raced by definition, so a separate For time. with the same cap or none is represented; For time. Time cap: 18 minutes. scores the workout by time and leaves the cap to the section clock. EMOM for 12 minutes reads as { intervalSeconds: 60, rounds: 12 }. A first line that is exactly the model’s title, quoted or not (“Iron Triangle”), is the title’s source; the title drops the quotes. A rest-only line (Rest 2 minutes between sets.) joins the section description whether the model files it as a standard or a timing instruction.
  • A block’s own structure line counts wherever the model files it — as a timing or standard instruction, or left in its remainder: a full-line round count (3 rounds, not for time.) or a rep scheme (21-15-9 reps of:). A rep scheme next to For time. makes the block that rep scheme, against the same cap. Prose never sets a shape: a round count must fill its line.
  • Use original as freeform discards the parse draft and opens the existing freeform editor with the complete original text. It removes parse-route state while retaining day/session context; saving uses the normal freeform path.
  • Source text, spans and review metadata remain on the parse job. Coach guidance and retained operative instructions travel through normal workout creation/update; the backend does not trust a client acknowledgement alone.

The deterministic regression suite includes explicit five-session headers, symbol-separated class blocks, source-only programming, repeated complexes, prescription continuations, conditional equipment, unsupported clock interruption, ambiguous exercise resolution and commit preservation failures. fast-check adds 940 bounded generated cases per run: 440 session-boundary mutations, 400 canonical/MCP/builder prescription round-trip or rejection cases, and 100 set-prefix formatting cases. Seeds are fixed in the specs; failures report a shrinkable seed/path. These tests do not call a live LLM or incur provider cost. They are discovered by the existing test:unit:api CI job, including the shared-library specs; no separate slow test job is required.

Parse pipeline

ParseOrchestrationService.runParse:

  1. Normalize input CRLF→LF once; the normalized text is what gets persisted and what every span ([start, end) char offsets) points into.
  2. Insert the job row first (status: 'draft') — a failed LLM call still leaves an audit row.
  3. Extraction (ExtractionService.extract): one non-streaming call, MODEL_POLICY.parsePaste (ADR-0022): claude-sonnet-5, forced tool use (tool_choice: { type: 'tool', name: 'record_extraction' }), thinking: { type: 'disabled' }, max_tokens: 16000, cached system prompt. A day-grain text naming two or more parts is instead read part by part by MODEL_POLICY.parseDayPart (Day grain). Output is validated against extractionResultSchema; one schema-error retry is allowed. A reply that ends with stop_reason: 'max_tokens' fails immediately as parse_failed, without the retry: re-emitting under the same cap cannot finish it. Movement spans require exact verbatim line agreement; ordered source alignment repairs unambiguous bad offsets, while ambiguous occurrences remain {start:0,end:0}. Timing/standard instructions and section-local load directives retain their own verbatim text and spans. With PARSE_EXTRACTION_FIXTURES=1, the API is bypassed for a matching extraction fixture.
  4. Meter the spend immediately — computeCostUsdMicros + AgentCostTracker.recordTurn record the model call even if later structural validation rejects the draft.
  5. Language: mixed-language programming and guidance retain literal source text; non-English guidance alone does not reject the workout.
  6. Session boundaries: explicit multi-session text is rejected before extraction; a model result with workoutCount > 1 is also rejected. The parser does not silently slice a day to its first workout.
  7. Shape reconciliation (D7) per section — see table below. Superset groups via detectSupersetGroups (explicit A1/A2 markers or the literal word “superset” only; broken sequences → all-null).
  8. Prescription: the LLM’s partial guess is re-validated against PrescriptionSchema; invalid → null. Explicit section-local load directives apply only to their named movement mentions; movement-local loads win. Labelled absolute loads use load: { kind: 'absolute', unit: 'lb' | 'kg', bySex: { female?, male? } } with at least one label and no scalar value. Unlabelled historical slash values remain unchanged. Separate nonconflicting female/male defaults merge; conflicting defaults are not guessed.
  9. Resolution (D6): mentions deduped case-insensitively (normalizeMentionKey after cleanMention strips list/superset markers and trailing 5x5-style fragments), searched via ExerciseSearchService.search(orgId, { q, mode: 'hybrid', limit: 5 }), max 4 concurrent. A search that throws → that mention is unresolved, never a failed parse.
  10. Assemble ParseDraft (section keys s-0…, draft-wide movement keys m-0…), compute auto_payload via draftToSectionsPayload, update the job row (draft, payload, model, tokens, cost, stage_timings { extractionMs, resolutionMs, totalMs }), emit parse.completed, return the draft.
  11. Any other failure → job status: 'failed', error_code: 'parse_failed', emit parse.failed, throw sanitized 500 parse_failed.

Resolution confidence (D6)

Resolution checks the complete organization-visible exact-name and alias lookup before fuzzy search. normalizeExerciseIdentity folds case, spacing, apostrophes, dash variants and supported English plural forms consistently in SQL and JavaScript. A private organization fork takes precedence over its canonical source only for that organization.

ConditionStatusSelection
One exact name, or one exact alias with more than one wordautoExact exercise
Several exact owners, or a one-word alias without an exact namesuggestedCoach must select
Fuzzy candidates onlysuggestedCoach must select; rank is not confidence
No candidates or lookup failureunresolvedCoach resolves or explicitly preserves instructions

A semantic rewrite cannot silently select an exercise. Candidate display limits never hide an exact-name collision from the resolution decision.

Day-grain resolution (workouts_parse, or a paste that heads two or more parts)

runParse({ dayGrain: true }) passes dayGrain: { sections } to resolveMentions; a paste that heads fewer than two parts resolves exactly as above. Strict confidence still applies. Before ranked search, each mention is tried against the exact name/alias lookup in folds, least changed first (mentionVariants, resolution-vocabulary.ts): as written with parentheticals as a note (“Jerk (of choice)”); the last word’s singular (-ies→y, -s, -es: “Hang power cleans”, “banded good mornings”); spelled-out shorthand (MOVEMENT_ABBREVIATIONS: OHS, T2B/TTB, C2B, HSPU, DU, KBS, RDL, SDHP, MU/BMU/RMU, HPC/HPS, C&J, and KB/DB/BB as words); then without note words (MOVEMENT_NOTE_MODIFIERS: PVC, empty bar, banded, light, easy, slow, heavy, tempo, paused, alternating, controlled), which become resolution.note and so the movement’s notes. A fold that selects wins; else the first exact match (a suggestion). Folds only build queries: the library decides through the same JS/PostgreSQL identity pair, so a fold that names nothing matches nothing and normalizeExerciseIdentity is unchanged. isNonExerciseMention (“Empty bar”, “easy”, “machine of choice”, “singles”, “rest”, “bar”, a mention of only note words) never resolves: unresolved, no lookup, no confirmation. “Row or Bike” / “A/B” resolves its first option; the other options that select become prescription.alternatives (unless the parse already set some). Last, every mention still suggested goes in ONE batched call to RESOLUTION_CONFIRMATION_MODEL (Haiku, ResolutionConfirmationService) with its line, part and block and its candidates; it confirms one candidate id or none (an id it was not given is ignored; answers cached 30 days by mention+line+context+candidate ids; a failure or no key confirms nothing). A confirmed mention becomes auto with resolution.confirmedBy: 'model', and workouts_parse lists it in an auto_confirmed_movement warning.

Shape reconciliation (D7)

Grammar (detectShape)LLM shapeGuessResult
matched(shape ignored)grammar’s shape, shapeSource: 'grammar', confidence high. Grammar reads the display header plus explicit timing instructions. Its clock facts win; a validated LLM configGuess may fill absent keys.
no matchpresentLLM’s shape, 'llm' / low (flagged in preview); configGuess validated against SECTION_SHAPES[shape].config, invalid → null
no matchabsentlinear, 'none' / high, body preserved

Shapes: amrap, emom, for_time, rounds, tabata, rep_scheme, intervals, else linear. 5x5 on a line that carries a movement mention is a prescription, not a shape (hasMention guard).

In fidelity mode, sequential Then N rounds: headings start separate ordered sections, keeping an initial cardio block outside those repeats. A unique literal strength heading can supply the exercise for load-only lines below it: Back Squat with four build-up lines and 5×5 @ 105–110 kg produces five movement rows and nine prescribed sets. Each row retains its literal load-line span and a validated mentionSpan pointing to the heading. Empty bar stays a load intent without an invented weight. Generic or multi-lift headings require review. Load-only strength rows without matching structured prescriptions block commit; preserving those sets only as prose cannot satisfy fidelity review. Full source headers retain explicit For Time; coaching targets and negative guidance do not create time scoring or caps.

Explicit standards become the section’s operative description (for example, the shuttle-run definition). Score-posting requests and the complete stimulus/strategy block stay in parse-job source/remainder, not workout comments. On a 15-minute clock, for time produces for_time with timeCapMinutes: 15. At 15 minutes, on a 3-minute clock produces timed linear with startOffsetSeconds: 900 and durationSeconds: 180; the offset is absolute workout time, not rest. Builder serialization preserves nonempty linear timing config and sex-labelled loads through no-edit commit.

Day-grain prescription rules

Every parse applies them: ExtractionService.extract appends DAY_GRAIN_EXTRACTION_RULES to the system prompt and its span repair matches plain vs typographic quotes; the parse’s detectShape calls read EMOM interval ranges; namesSection, reconcileProgramming and deriveSectionScoreSlots apply them unconditionally. They do NOT turn on parts: those are workouts_parse and any paste that heads two or more parts (dayGrain, below). Recorded extraction fixtures are keyed by input text only.

Read deterministically from the coach’s lines in reconcile-programming.ts (the prompt asks the model for the same, the source lines win):

  • 21/18 Cal row, 12 cal bike, 10 Bike calories → reps.kind: 'calories'; a slash pair is bySex { male, female } (male first). Any other slash pair (15/12 Wall balls) sets no reps.
  • “Score - total T2B” → a reps slot.
  • Mentions that name no exercise stay block text: rep-scheme words (Sets, 10 reps, climbing) and the resolver’s non-exercise vocabulary (isNonExerciseMention: Empty bar, easy, machine of choice, singles, rest — an empty-bar row of a lift’s ramp stays that lift’s row). Model-supplied alternatives exercise ids are dropped (the “or Bike” stays in notes).
  • 200m Run, 1.5 km row, 1 mile run → distance { value, unit: 'm' | 'km' | 'mi' }, replacing any model rep count.
  • A movement the model returned over several lines (Every 2:00 x 8 sets -\n1 Clean lift-off, Back Squat\n…\nPercentages off your Back Squat 3RM) is narrowed to the line naming it during span repair (the first such line starting with a count, else the first); a narrowed copy of a line the model also listed is dropped. The clock, set bands and reference line are then read as the block’s own lines.
  • @24/20” / @24" / @60cm → height (bySex or value, in/cm); the @ cue is never a load.
  • Level 1 = 10 burpees, Levels 3 & 4 = 15 burpees, Level 2 = 40/30kg → levels on the one movement they apply to: the movement a level names, else the only movement a bare load can take (not calories / distance / height). A level naming no movement in the block (Level 1 = 10 BFB) goes to the one movement whose own line leaves its reps open. All of a block’s level lines apply or none do. Level loads keep unlabelled slash values as written ('35/25').
  • @75% of your Back Squat 3RM and a block line Percentages off your Back Squat 3RM / Heavier than your last complex → percent_reference with reference { label, repMax? } on every percent load and band in the block. ”% of your max” stays percent_1rm.
  • Every 3:00-3:30 for 3 sets → EMOM { intervalSeconds: 180, intervalSecondsMax: 210, rounds: 3 } (grammar).
  • Per-set loads (2 Sets @6RPE, Sets 1-2 - 76-80%) that add up to the block’s set count become set_progression. One movement owns them; several movements done each set of a non-alternating EMOM/rounds block are a complex: they become superset group A (labels A1, A2, …) and the first carries set_progression + group_load: true.
  • @building / @heavy → load.kind: 'intent'.

Every parse (movement-annotations.ts, applied in runParse after the read, before auto_payload). Lines a coach writes under a movement that annotate it rather than prescribe work:

  • A link-only line (https://… and nothing else, a valid media URL) is never a movement, never an unconverted_movement_line and never an unconverted instruction: it does not block workouts_parse save or the paste review. It stays in the block’s verbatim sourceText (tappable on the member’s board) and becomes mediaUrl of exactly one movement when the block says which, first match wins:
    1. Footnote: the link sits in, or directly before/after (blank lines between allowed), a paragraph holding a *-footnote line (*KBs hanging…), and exactly one movement line of the block carries the * marker (5 Earthquake bar overhead squats* (…)) → that movement.
    2. Right under a movement: the line directly above the link (no blank line between) is exactly one movement’s line → that movement.
    3. The block’s only movement. Otherwise nothing is attached. Two different links pointing at one movement attach neither. A URL is only ever taken verbatim from the text.
  • Offered alternatives: a Pick one: / Choose one: / pick one of …: lead (also at the end of a footnote: Don't have it / don't like it? Pick one:) with its list lines below it in the same paragraph, and a swap line if you don't have X → sub with Y (also , sub with, use, swap for, and No X? Sub with Y). The option lines and the swap line are never movements (a model’s movement on them is dropped in reconcileProgramming) and never unconverted_movement_line. The options belong to: the *-footnoted movement when the lead is in a footnote paragraph and exactly one movement line carries *; else the one movement whose name holds every word the lead says is missing (a sled → Reverse sled drag; pointer words like “it” name nothing); else the movement above the lead. Each option (count stripped: 20m Bottoms-up KB overhead carry (each arm) → Bottoms-up KB overhead carry (each arm)) is matched exactly as the other options of “Row or Bike” are (ResolutionService.resolveAlternatives: day-grain folds, exact and selecting only, never the movement’s own exercise or a repeat, four at most, after any it already has). An option the library does not name exactly is left out of the structure, like an unmatched “or Bike”; its words stay in sourceText. It never blocks save.
  • Link lines, option lines, swap lines and a lead that is only Pick one: count as represented source; a footnote’s own words (KBs hanging from an empty bar…) are an unconverted instruction like any coach note.
  • The draft movement carries mediaUrl (parsedMovementSchema); draftToSectionsPayload (auto payload, the workouts_parse proposal and its save) and the web review’s draftToBuilderSections carry it into the commit, so a saved day stores it on workout_movements.media_url.
  • Capacity’s dips/sled block (8-12 Strict dips / into 30m Reverse sled drag / if you dont have a sled → sub with 15 tibialis raises / link): tibialis raises become the sled drag’s alternative; the link attaches to nothing — it follows the swap line, not a movement line, there is no footnote, and the block has two movements. A two-movement list closed by a link after a blank line (hamstring slider curls / hanging leg raises) also attaches nothing.

Day grain (RunParseArgs.dayGrain: workouts_parse, or a paste that heads two or more parts)

The MCP workouts_parse tool always sets it. Any other paste — the dashboard paste, “Structure this workout” — sets it when its own text heads two or more parts (detectDayParts before any model call); a paste with fewer keeps the single-extraction, section-per-heading draft. One whole-text extraction of a long class is not stable: live reads of the same numbered six-part paste folded everything after the warm-up into it, dropped the closing parts, or invented blocks, run to run. Read part by part, the same paste came back with the same parts and blocks every time. A first line wrapped in quotes (“Iron Triangle”) is the workout title; the rest of the opening is the day brief (workout.description). With it (day-parts.ts, day-part-extraction.ts):

  • Parts. A line is a part heading when it is a bare name (≤6 words, after an outline number such as 1. or 2)) that is not an extracted movement line and is a part word (Warm-up, Lift, Strength, Strength Successory, Successory, Conditioning, Hybrid Conditioning, Skill, WOD, Accessory, Cooldown, …), the first heading after a <<>>-style delimiter, or carries a m:ss-m:ss clock window. Clock windows (0:00-11:00 Warm-up, 0-11 Warm-up) set clockStartSeconds/clockEndSeconds and leave the title bare; Warm Up (10:00) and 1. Warm-up (15 min) are placed from the start of the day or the previous part’s end, else the duration stays in the title. Optional … sets optional: true (title kept). Keys are slugs without “optional”, unique per day. A day needs two parts that hold blocks; otherwise nothing changes.
  • Read part by part. When the raw text names two or more parts, each part’s text (heading to last line, without the delimiter) is extracted on its own — PARSE_PART_CONCURRENCY (4) at a time, by MODEL_POLICY.parseDayPart (claude-sonnet-5-5; tool_choice: auto, no thinking — thinking: between_tools) — its spans moved onto the day’s text and the answers stitched into one extraction (one workout, no model title: a part’s call would offer “Lift”). A part can no longer fold into another, and a single long call that stopped sectioning part-way is gone. Each round’s calls are metered at once (D9). If every part’s call fails the parse fails as before (parse_failed). Text with fewer than two parts keeps the single extraction.
  • Parts found again. After the first round the parts are detected again around the extracted movements: a line the split took for a heading but the model read as work (10-15 Face pulls right after <<>>) is no part, and its text joins the part above — no extra call. With fewer than two parts left, the text is read whole like any other. Because the parts were read one by one, the day stands even when only one part holds blocks; each other part is checked and reported on its own.
  • Cost. The extraction prompt and tool schema (~16k tokens) are cached for 1 hour (cache_control on the system block). With no live part read by the model in the last 55 minutes, one part is read first and the rest after it, so they read its cache instead of each writing it. Each part’s answer is kept for 30 days (AiCacheService, operation parse_extraction) keyed by org, model, first read vs re-read, a digest of the prompt + day-grain rules + tool schema, and the part’s text: a re-sent unchanged day (or an unchanged part of an edited one) makes no model call, and resolution still runs against the current library.
  • Checks (checkDayPart, per part of the assembled draft): extraction_failed (its call returned nothing usable), missing_blocks (text but no block), block_off_line (a block starting mid-line, outside its part, or right under another block’s movement), no_movements (a clock, sets or reps line but no movement), unconverted_movement_line (a counted line — 10 air squats, 200m Run, 21/18 Cal row, Max T2B — the work after 2:00 General -, or a bare name listed under a clock or another movement, that is not a movement; an into / then into transition, a link-only line, a Pick one: list and a swap line are never one — Links and offered alternatives).
  • Escalation. Only a part failing its own checks — or whose call failed — is extracted once more by MODEL_POLICY.parseEscalation (claude-sonnet-5-5 again, now reasoning first: adaptive thinking at medium effort). Its answer replaces the part’s and the draft is checked again; extraction_failed stays only when no round answered the part. The part carries escalatedModel, and parse.completed carries escalatedPartCount (+ escalationModel when > 0) and stageTimings.escalationMs.
  • Never silently wrong. A part still failing becomes a structural issue { reason: 'unstructured_part', partKey, text: <heading line>, failedChecks }; the draft keeps every part it could read, and workouts_parse refuses to save while it stands, naming the part.
  • Blocks. Sections stay sections inside their part (General, Activation, …). A section spanning two parts is split; a block that repeated the part heading as its header loses it and takes its own first name line. Blocks a model returned wrong are put right from the lines: a block starts at its line; one holding no text (the part heading as its own section) is dropped; a movement-less block overlapping another is folded into it; a block that was only the next one’s heading (Every 2:00 x 8 sets - over 1 Clean lift-off …), or that has no name of its own and continues the list above (1 Jerk dip / 1 Jerk (of choice)) or only comments on it (Highest average for the day.), is folded into the block above.
  • Notes and brief. A part’s closing paragraph written entirely in Hebrew/Russian/Arabic becomes part.note; Hebrew lines inside a block become that block’s description; the text above the first part becomes draft.workout.description. Remaining guidance is still the coach note.
  • Sessions. Part headings never trigger parse_multiple_sessions: parts are one workout. Explicit Day N / Session N / Workout A / date headers still reject before extraction.
  • The draft gains parts: [{ key, span, noteSpan, escalatedModel? }]; draftSectionSource (shared) clips a part’s last block before its note, delimiter and the next heading, so block sourceText is only the block. draftToSectionsPayload carries part on every block.

MCP workouts_parse

Leaf ParseAgentTools (apps/api/src/ai/agent/tools/leaves/parse.tools.ts), router workouts, staff roles, confirm: 'never', on both MCP and Spotter. Its input/output contract (date, save.resolutions, titles, warnings) is documented in mcp-server. Same pre-flight as POST /parse: workout_builder tier gate, PARSE_MAX_INPUT_CHARS (parse.blocked too_long), budget pre-check (ai_budget_exceeded), parse.requested with the calling channel. Runs runParse({ dayGrain: true }) (above), preserves unconverted lines into their block’s description, runs SectionFidelityService.fill; a draft with structural issues (incl. unstructured_part) is never saved.

Auto payload (under-structure doctrine)

draftToSectionsPayload includes only auto-resolved movements. Every other movement’s raw line is appended to the section description under "Needs review (not auto-matched to an exercise):". This payload is persisted as auto_payload. Legacy commits use it as their correction baseline; fidelity commits use the complete stored draft.

Commit / discard

ParseCommitService (job must be org-scoped and status: 'draft', else 404 / 409 Conflict — a plain Nest conflict body with no code):

  • Paste flow (job.workout_id null): WorkoutsService.create(orgId, clerkId, { …meta, mode: 'structured', sections, isLibrary }) — one call, inline sections. Only an explicitly supplied coach description is persisted; raw input stays on the parse job. isLibrary (default true) applies only to creation.
  • “Convert to structured workout” flow (job.workout_id set): one atomic WorkoutsService.update of metadata, structured mode, and sections. The old freeform description is cleared unless an explicit coach description is supplied. No new write path against workout tables (D11); existing membership/tier validation still applies.
  • For a stored draft with fidelityMode: true, the correction baseline includes every draft movement, including suggestions. Confirming or creating the exercise for an unresolved slot does not count as adding a movement; changing an auto-selected identity still counts as a swap. Prescriptions and section configuration are compared against the stored draft. Legacy commits retain the auto_payload baseline. The job stores final_payload, delta, and workout_id; parse.committed includes deltaBaseline (full_draft_v1 or auto_payload) alongside editDistance, zeroEdit, swap/add/remove counts, exercisesCreatedCount, and timeToCommitMs. Compare telemetry within a baseline version.
  • Publish (TKN-320): when ParseCommitDto.publishTo ({ classTypeId, date }) is present, commit also calls DailyProgrammingService.set(orgId, clerkId, { classTypeId, date, status: 'published', entry: 'paste', workouts: [{ workoutId, sortOrder: 0 }] }) in the same request — the single call that publishes the day, emits wod_published, and emits wod_post_completed { entry: 'paste', scope: 'day' } (see docs/features/daily-programming/behavior.md). Absent publishTo, commit never touches daily programming.
  • Discard → status: 'discarded', parse.discarded.

Web flow

Entry routing is shared. The preselection and non-blocking save behavior below describes legacy drafts only; fidelity-mode drafts use the mandatory review and preservation rules above.

/dashboard/workouts/new is the only creator route. It opens the freeform form first; its “Convert to structured workout” button swaps the same route to the parse input, carrying the current text through the existing one-shot handoff.

  • Direct root: freeform is the default. ?mode=translate opens the tier-gated parse surface for contextual schedule links, while ?jobId= reloads a shareable draft preview.
  • Day/session context: the board and session sheet link to /dashboard/workouts/new?mode=translate&classTypeId=&date=&scope=day&returnTo=…. classTypeName/sessionCount only label the review’s publish button.
  • The parse surface wraps in <FeatureGate feature="workout_builder" fallback="upgrade-card">.
  • Preview (parse-preview.tsx, readOnly prop): left = source text with claimed-span vs remainder highlighting (hover sync with the tree); right = the board the member’s app draws (VerbatimWorkout, via verbatimSectionsFromBuilder — reused, not forked): the coach’s own text with the resolved movements linked inside it, seeded from the same useBuilderState tree draftToBuilderSections builds. A movement with no resolved exercise at all (unresolved, zero search candidates) keeps its words but carries no link — same filtering buildSectionsPayload applies at commit. A low-confidence section shape shows its chip at the head of that section. Outstanding (non-auto, unactioned) movements are listed separately in a small panel below with the same resolution popover, purely optional. The non-readOnly mode (the interactive SectionEditor tree) still exists, unused by any paste-first caller — kept only so existing tests/behavior for a future editable use aren’t disturbed.
  • Adjustment (movement-resolution-popover.tsx): per non-auto movement — pick a candidate, search the library, create an org-local exercise (name-prefilled, POST /exercises — which now enqueues embedding enrichment, D10), or drop-to-note (clears the movement, appends the raw line to the section description).
  • Publish/save bar — never blocks (TKN-320): a still-unactioned movement no longer blocks the primary button. finalize-for-publish.ts’s autoDropUnresolvedMovements runs right before commit: any movement still lacking an exercise gets its raw line folded into the section description (nothing is silently lost); a suggested movement (pre-filled with its top candidate by draftToBuilderSections) keeps that candidate with a confirmation note. The bar shows a soft “N movements to confirm later” count (toConfirmLater, never disables anything) plus an “Also save to Workouts for reuse” checkbox (isLibrary — unchecked by default with a day/session context, checked by default on Workouts › New).
    • With a day/session context: primary button reads “Publish to {weekday} · {classType} ({n} sessions)”; commit sends publishTo and returns to the board/session sheet.
    • Without one (Workouts › New, freeform “Convert to structured workout”): primary button reads “Save workout”; commit omits publishTo and redirects to the new workout’s page.
    • “Adjust in builder”: commits the parse as a workout first (respecting the checkbox, never publishing — even with a day context, the builder’s own save chain owns that), then opens the real structured builder on the committed workout (session-slot context + day scope when a session is known; the plain library edit route otherwise) and returns to the origin on save.
  • Discard behind an AlertDialog. Input is dir="auto"; layout uses logical properties (RTL-safe).

Error codes

CodeHTTPWhenExtra fields
parse_feature_disabled403Never raised by the current API; kept in the schema and mapped on web for older builds
parse_too_long422Input > 10,000 chars
ai_budget_exceeded429Daily or monthly org AI budget breached (parse only)period: 'day' | 'month'
parse_english_only422Legacy extraction detected non-English (D3)
parse_multiple_sessions422 parse / 409 commitFidelity mode detected more than one sessionworkoutCount on parse
parse_review_required409Fidelity review is incomplete or required source was omitted
parse_failed500Any pipeline failure after the gates

Body shape: { code, message, ...extra } (ParseHttpError). Not code-carrying: plain 403 (role/tier), 404 (job not found), 409 (commit/discard on a non-draft job — surfaced client-side as the generic failure toast). The web maps each code to a localized inline error (paste-input.tsx).

Permissions

RoleParse / view / commit / discard
owner, admin, coach✅
member403 at the controller
Non-member of the org403 (requireMembership)

Tier matrix

TierBehavior
Lite (no workout_builder)Entry points visible but lock-styled; paste route shows the upgrade card; API 403 (tier guard)
Pro / Elite✅ Full feature

Failure modes

FailureSurfaceJob row
Anthropic down / invalid tool output twice500 parse_failedfailed + error_code
Voyage / exercise search downParse succeeds; affected mentions unresolveddraft
Cost-tracking write failsLogged warning; parse continuesunchanged
Double commit (race / stale tab)409; generic failure toastfirst commit wins
PostHog unreachableFeature hidden + 403 (fail-closed)no row created