Repository navigation
ci(roadmap): fail validation when a change adds required work to a completed epic #161
Description
Reframed on 2026-10-16 as one implementable CI task. Verified against main at 04a2816: nothing of this exists yet. roadmap/scripts/validate.py, health.py and completion.py never read lifecycle; .github/workflows/roadmap-validate.yml checks out the PR head only (no fetch-depth, no merge base) and runs validate.py roadmap/epics on the final tree. The trigger case #152 was closed unmerged and split into #162 (open, no FinOps change). roadmap/epics/agent-finops.yaml is still lifecycle: completed.
Invariant
A roadmap change must not add a new required work item to an epic whose lifecycle is completed while leaving that lifecycle completed. To extend completed work, the same change must add the item as optional or move the epic back to active (or paused).
Work
1. Diff-aware check script
Add roadmap/scripts/lifecycle_guard.py (name indicative) that takes a base ref (or a base directory of manifests) and the head manifests and, for every epic whose head lifecycle is completed:
- fails if the set of
required: truework-item ids grew relative to base; - fails if an existing item flipped from
required: falsetorequired: true; - fails for a manifest that is new in head, born
completed, and holds a required item that is not bound and closed; - passes if the same change moves the lifecycle from
completedtoactiveorpaused(that is the explicit, reviewed reopening); - treats an unreadable base as an error, never a pass: a missing merge base, a shallow checkout without the base commit, an unreadable or malformed base manifest each exit non-zero with a distinct message. Per AGENTS.md "could not observe" is never "observed absent".
A manifest that is deleted in head is not a violation of this check.
2. Wire it into roadmap-validate.yml
- On
pull_request: check out with enough history to resolve the merge base (fetch-depth: 0or a targeted fetch ofgithub.event.pull_request.base.sha), computegit merge-base, and run the guard between that commit and the head tree. A failed merge-base lookup fails the job. - On
pushtomain: run the guard against the parent commit (github.event.before) or skip with an explicit "no base" notice. Do not silently pass. - Keep
permissions: {}.
3. Tests
- Unit tests in
roadmap/tests/test_lifecycle_guard.pyusing in-memory or fixture manifests for every bullet in section 1, including the error cases for an unreadable base. - Add the mutation to
roadmap/tests/mutations.pyso the existing mutation harness exercises it. - Prove it both ways, as AGENTS.md requires: green on the current
roadmap/epicsagainst its own parent, red on a branch that reapplies the feat(roadmap): add adaptive agent runtime assurance #152 FinOps hunk (add a requiredtrajectory-economicsitem toagent-finops.yamlwhile it stayscompleted), and red again on that mutation when the guard step is removed from the workflow. Paste the three run links in the PR.
4. Document
One paragraph in roadmap/README.md under the validation section: what the guard checks, how to reopen a completed epic legitimately.
Out of scope (separate issue if wanted)
A state-based check that flags an epic whose manifest says completed while completion.py computes incomplete. It catches drift without a manifest diff (a required issue reopened on GitHub) and is complementary, not a substitute. Open it as its own issue if the implementer thinks it is cheap; do not fold it into this PR.
Acceptance
- The guard fails on the feat(roadmap): add adaptive agent runtime assurance #152 mutation and passes on roadmap: add agent runtime assurance epic (split of #152, no FinOps change) #162 (the split without the FinOps commit) rebased on current
main. - An unreadable base, a missing merge base, or a malformed base manifest is a failed check with a distinct error, never a pass.
python3 -m unittest discover -s roadmap/tests -p 'test_*.py'stays green.
History
Original body (2026-10-16, before reframing):
Invariant
A roadmap change MUST NOT add a new required work item to an epic whose lifecycle is
completedwhile leaving that lifecyclecompleted. To extend completed work, the same governed change must either add the item as optional or explicitly transition the epic back toactive.Why
#152 adds a required
trajectory-economicsitem toagent-finops.yaml. That epic becamecompletedin #155 after #152 was opened. The merge is textually clean, because #155 touched only thelifecycleline, and on the rebased branch every current check passes:validate.pyover all 18 epics and the 318 roadmap tests. The result would be a manifest that sayscompletedwhile holding an open, unbound required item. Nothing reports that:
lifecycleappears only in the schema enum and inpublish.py, which copies it into the publication.validate.py,completion.py,health.py,reconcile.py,plan.pyandready.pynever read it.completion.pywould computeincomplete, and no check compares that withlifecycle: completed. The reconciler would only noteBindingUnbound.Both Claude approvals on #152 were given against a base where FinOps was still
active, so no reviewer saw the conflict either.Shape of the check
It must be diff-aware against the base. A validator that reads only the final YAML cannot tell "a required item was added after completion" from "this item was always there". Proposed:
- In
roadmap-validate.ymlonpull_request, compare every manifest underroadmap/epics/against the merge base.- For each epic whose head lifecycle is
completed: fail if the set ofrequired: truework-item ids grew relative to base, or if an existing item flipped fromrequired: falsetotrue. The same applies to a new manifest that is borncompletedwith an item that is not bound and closed.- Pass if the same change moves the lifecycle from
completedtoactiveorpaused. That is the explicit, reviewed reopening.Possible second, state-based check
As a separate check: health or publication flags an epic whose manifest says
completedwhile the computed completion is notcomplete. This catches drift that arrives without a manifest diff, such as a required issue reopened on GitHub. It is complementary to the diff check, not a substitute.Acceptance
- The guard fails on feat(roadmap): add adaptive agent runtime assurance #152 as it is today (the deliberate mutation) and passes on the split without the FinOps commit.
- Per AGENTS.md, it is proven both ways: green on a converged input, red on the mutation, and red again when the fix is reverted.
- An unreadable base, such as a shallow checkout or a missing merge base, is a failed check, never a pass.
Activity
[-]roadmap: fail CI when a change adds required work to a completed epic[/-][+]ci(roadmap): fail validation when a change adds required work to a completed epic[/+]mctl-agents commentedon Oct 15, 2026
mctl-agents issue-investigator has analyzed this issue and created a proposal:
https://github.com/mctlhq/mctl-gitops/tree/main/platform-gitops/agents-state/.github/proposals/issue-161-ci-roadmap-fail-validation-when-a-change/
Status:
proposed— pending human approval. Reviewrequirements.md,design.mdandtasks.md, then approve: signal this issue's DevLoopWorkflow (mctl-apiPOST /api/v1/agents/dev-loop/dev-loop-mctlhq-.github-161/approve, orpython -m orchestrator.temporal.cli approve dev-loop-mctlhq-.github-161 --approver <your-identity>) — the workflow flips.status.yamltoacceptedvia themctl-agents-approveoperation and runs the Tier 2 implementer. If no DevLoopWorkflow is running for this issue (pre-Temporal proposal), run themctl-agents-approveoperation directly withservice=.github slug=issue-161-ci-roadmap-fail-validation-when-a-change.The approver is recorded in
.status.yamland in the gitops commit, and the implementer refuses a proposal whosecontrol.requires_human_approvalcarries no named approver — so prefer the mctl-api endpoint, which takes the approver from the authenticated caller rather than from what the caller types.chore(agents): investigate mctlhq/.github#161 2026-10-15