Powered by AppSignal & Oban Pro

Diagnosing Errors — A Tour

livebooks/03_diagnosing_errors.livemd

Diagnosing Errors — A Tour

Mix.install([
  {:wpl_validator, path: Path.join(__DIR__, "..")},
  {:jason, "~> 1.4"},
  {:kino, "~> 0.13"}
])

Goal

Walk through every error code WPL Validator can emit, see what triggers it, and how to fix it. Each section loads a real fixture from priv/conformance/invalid/, validates it, then shows a corrected version.

defmodule Tour do
  @conf Path.join([__DIR__, "..", "priv", "conformance", "invalid"])

  def load(name) do
    @conf |> Path.join(name) |> File.read!() |> Jason.decode!()
  end

  def validate(plan, opts \\ []) do
    WPL.Validator.validate(plan, opts)
  end

  def render(plan, opts \\ []) do
    result = validate(plan, opts)

    rows =
      Enum.map(result.errors, fn e ->
        %{severity: e.severity, code: e.code, path: e.path, meta: inspect(e.meta)}
      end)

    Kino.Layout.grid(
      [
        Kino.Markdown.new(if(result.valid?, do: "**valid? true**", else: "**valid? false**")),
        Kino.DataTable.new(rows)
      ],
      boxed: true
    )
  end
end

:schema_violation — Pass 1 stops everything

When the JSON Schema rejects the document, Pass 2 (semantic checks) is skipped entirely. Fix schema violations first, then re-validate.

Tour.load("schema-violation-bad-enum.json") |> Tour.render()

Fix: change "type": "intergalactic_disco_party" to one of the allowed plan types (workout, nutrition, meditation, recovery, hybrid).

:duplicate_id — sibling collision

Within a scope (plan/phase/week/day), every id must be unique. The error points at the second occurrence and includes a :first_occurrence JSON Pointer.

Tour.load("duplicate-day-id.json") |> Tour.render()

Fix: rename the offender. The walker tracks scopes hierarchically:

  • phase ids are scoped to plan
  • week ids are scoped to phase:
  • day ids are scoped to week:
  • block & activity ids are scoped to day: (so two activities can share an id in different days).

:unresolved_ref — catalog-only check

exercise_ref values must exist in the catalog you pass via the catalog: option. If you don’t pass a catalog, this check is skipped entirely.

plan = Tour.load("unresolved-exercise-ref.json")

# Without catalog — no error
Tour.render(plan)
# With catalog — surfaces the unknown ref
Tour.render(plan, catalog: %{exercises: MapSet.new(["push_up", "squat"])})

Fix: add the missing exercise to your catalog, or change the exercise_ref to one that exists.

:invalid_prescription — sets_reps without sets or reps

A sets_reps prescription needs at least one of sets or reps populated.

Tour.load("invalid-prescription-sets-reps.json") |> Tour.render()

Fix: add "sets": 3 or a "reps": {"target": 10} block.

:invalid_personalization_rule — unknown action type

Personalization actions must use a known type. The full list: modify_intensity, add_warmup_time, increase_rest, reduce_sets, reduce_reps, replace_exercise, exclude_exercise, modify_exercise, use_schedule, add_activity.

Tour.load("invalid-personalization-action.json") |> Tour.render()

Fix: replace "set_world_on_fire" with a known action type.

:invalid_personalization_rule — bad action scope

Action scope must be one of: activity, block, day, week, phase, plan.

Tour.load("invalid-personalization-bad-scope.json") |> Tour.render()

Fix: change "scope": "galaxy" to one of the allowed scopes.

:invalid_points_rule — negative points

Points must be a non-negative integer.

Tour.load("invalid-points-rule.json") |> Tour.render()

Fix: change "points": -10 to a non-negative number.

:empty_phases_for_type — workout without phases

Plans of type workout or hybrid need at least one phase.

Tour.load("empty-phases-workout.json") |> Tour.render()

Fix: add a phase, or change the plan type to one that doesn’t require phases (nutrition, meditation, recovery).

:phase_duration_mismatch — warning, not error

If a phase declares duration: 3 weeks but the weeks array has 2 items, you get a warning. Warnings don’t invalidate the plan but indicate likely authoring mistakes.

Tour.load("phase-duration-mismatch.json") |> Tour.render()

Fix: either bring the array length in line with duration, or remove the duration field entirely (it’s optional).

:cyclic_subplan — sub-plan reference cycle (preview, schema v1.5.0+)

> Preview feature. SubPlanActivity is reserved schema surface for > a future plan-marketplace / library-composition use case. Most plans > should not use it — express composition by inlining content. The > validator rule below exists so the surface is not foot-gun-prone if > you do choose to use it.

A SubPlanActivity whose sub_plan_ref points back to the containing plan’s own id creates a self-cycle. Compiling such a plan would loop forever; the validator catches it before consumers do.

Tour.load("cyclic-subplan-self.json") |> Tour.render()

Fix: change the sub_plan_ref to a different plan id, or remove the sub-plan activity entirely. Cross-plan cycles (A → B → A) require a future sub_plans resolution map at validate time and are not yet detected by single-plan validation.

Summary

  • Pass 1 errors (:schema_violation) shadow Pass 2 — fix them first.
  • Pass 2 errors check things JSON Schema can’t express: cross-document uniqueness, ref resolution, semantic invariants.
  • Warnings (e.g., :phase_duration_mismatch) surface authoring mistakes without invalidating the plan.

For the canonical list and stability guarantees, see priv/conformance/error-codes.md.