{"record":{"id":"c272857b36331182","repo":"gastownhall/beads","slug":"loop-q-max-is-required-when-until-is-set","errorCode":null,"errorMessage":"loop %q: max is required when until is set","messagePattern":"loop %q: max is required when until is set","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/formula/controlflow.go","lineNumber":82,"sourceCode":"\tif loop.Count > 0 {\n\t\tloopTypes++\n\t}\n\tif loop.Until != \"\" {\n\t\tloopTypes++\n\t}\n\tif loop.Range != \"\" {\n\t\tloopTypes++\n\t}\n\n\tif loopTypes == 0 {\n\t\treturn fmt.Errorf(\"loop %q: one of count, until, or range is required\", stepID)\n\t}\n\tif loopTypes > 1 {\n\t\treturn fmt.Errorf(\"loop %q: only one of count, until, or range can be specified\", stepID)\n\t}\n\n\tif loop.Until != \"\" && loop.Max == 0 {\n\t\treturn fmt.Errorf(\"loop %q: max is required when until is set\", stepID)\n\t}\n\n\tif loop.Count < 0 {\n\t\treturn fmt.Errorf(\"loop %q: count must be positive\", stepID)\n\t}\n\n\tif loop.Max < 0 {\n\t\treturn fmt.Errorf(\"loop %q: max must be positive\", stepID)\n\t}\n\n\t// Validate until condition syntax if present\n\tif loop.Until != \"\" {\n\t\tif _, err := ParseCondition(loop.Until); err != nil {\n\t\t\treturn fmt.Errorf(\"loop %q: invalid until condition %q: %w\", stepID, loop.Until, err)\n\t\t}\n\t}\n\n\t// Validate range syntax if present","sourceCodeStart":64,"sourceCodeEnd":100,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/formula/controlflow.go#L64-L100","documentation":"Until-loops repeat while their condition is unsatisfied; without a Max cap they could loop forever, so validateLoopSpec requires Max > 0 whenever Until is set. Max=0 (unset) triggers this error.","triggerScenarios":"ApplyLoops with `&LoopSpec{Until: \"step.status == 'complete'\", Body: body}` and Max left at its zero value; Until set via variable substitution while Max was never populated.","commonSituations":"Copy-pasting an until-loop example and omitting the max line; assuming a default max exists (there is none); a schema that serializes Max=0 by omitting it, losing the cap.","solutions":["Set Max to a positive integer safety cap alongside Until: `&LoopSpec{Until: cond, Max: 10, Body: body}`","Pick Max from the workflow's realistic iteration budget (e.g. retries+slack) rather than leaving it unset","Add a pre-cook lint that flags any LoopSpec with Until set and Max==0"],"exampleFix":"// before\nloop := &formula.LoopSpec{Until: \"step.status == 'complete'\", Body: body}\nsteps, err := formula.ApplyLoops(steps) // max is required when until is set\n// after\nloop := &formula.LoopSpec{Until: \"step.status == 'complete'\", Max: 10, Body: body}\nsteps, err := formula.ApplyLoops(steps)","handlingStrategy":"validation","validationCode":"if loop != nil && loop.Until != \"\" && loop.Max == 0 {\n\treturn fmt.Errorf(\"until-loop on %q needs a positive Max cap\", stepID)\n}\nsteps, err := formula.ApplyLoops(steps)","typeGuard":"func untilLoopCapped(l *formula.LoopSpec) bool {\n\treturn l.Until == \"\" || l.Max > 0\n}","tryCatchPattern":"steps, err := formula.ApplyLoops(steps)\nif err != nil {\n\tif strings.Contains(err.Error(), \"max is required when until is set\") {\n\t\treturn fmt.Errorf(\"uncapped until-loop: %w\", err)\n\t}\n\treturn err\n}","preventionTips":["Always pair Until with a positive Max","Treat Max as a mandatory safety budget for conditional loops","Add a schema/lint rule rejecting until without max"],"tags":["formula","loops","validation","infinite-loop-risk"],"backgroundTag":"missing-required-field","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}