SPEC-021: Check the criteria before approving and across artifacts - #23
Merged
TheJisus28 merged 9 commits intoSep 20, 2026
Merged
Conversation
TheJisus28
deleted the
spec/021-check-the-criteria-before-approving-and-across
branch
September 20, 2026 22:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Spec: .forge/specs/SPEC-021-check-the-criteria-before-approving-and-across/spec.md
Problem
forge approverefuses only while## Open questionsis non-empty. Nothingchecks that an acceptance criterion is verifiable, and
forge validatechecks structure — ids, references, cycles, coverage between parent and
child — but not whether every criterion has a task that delivers it and, at
review time, an evidence line that settles it. A vague criterion, or a
criterion no task mentions, passes the gates untouched.
Acceptance criteria
forge approverefuses, or warns with a clear message, when acriterion cannot be verified by a command, a test, or a request and its
response; the rule is stated in the contract of this spec.
forge checkreports each criterion with no matchingtask in
tasks.mdand each criterion with no evidence line inreview.md, naming the criterion and the file it is missing from.forge checkexits non-zero when a criterion is uncovered while thespec is
reviewingordone.forge validateincludes the criterion-to-task andcriterion-to-evidence coverage as a warning, and as an error once the spec
is
done.docs/cli.mddocuments the check and the criterion rule.Contract
Decisions
1. A criterion is verifiable when its text names the evidence that settles
it;
forge approverefuses while one does not.internal/project/project.gogainsfunc (c Criterion) Verifiable() bool,the one place the rule lives. A criterion is verifiable when its trimmed
Textcontains at least one anchor:between them (
\forge check`,`tasks.md``, a test name, a field);test/tests(case-insensitive) or anidentifier matching
Test[A-Za-z0-9_]*;returns,prints,outputs,exits,succeeds,fails,refuses,rejects,reports,lists,names,matches,emits,responds.Those are the three evidence kinds
docs/workflow.mdalready names (acommand, a test, a request and its response), and they are the anchors every
criterion already in
.forge/specs/writes.cmdApproveininternal/cli/work.gogains a check beside the empty-contract andopen-questions refusals: it walks
s.Criteria(), collects the ids where!c.Verifiable(), and when the list is non-empty returns an error namingthem and saying to name the command, the test or the response that settles
each one. Discards: a warning instead of a refusal (the vague criterion
would still be approved, which is the problem the spec names); an explicit
verify:/[manual]tag (new syntax for every criterion and a parserchange); checking criterion shape in
forge validatetoo (approval is thegate AC1 names, and
forge checkstays about coverage). The fast lane(SPEC-016), which skips approval, is out of scope.
2.
forge check [id]is a new read-only command that reports the criteriano task and no evidence settles.
New
internal/cli/check.gowithfunc cmdCheck(args []string, out io.Writer) int,dispatched from
internal/cli/cli.gothe waycmdValidateis(
case "check": return cmdCheck(rest, stdout)) and added to the usage textand
docs/cli.md. It loads throughproject.Load(cwd()), readsspec.md,tasks.mdandreview.md, and writes nothing: noSave, nogit, nonetwork, so a before/after tree hash and
git status --porcelainareunchanged (the
forge capabilitiesdeterminism test, extended).project.NormalizeID), that spec; without one,every spec whose status is
implementing,blocked,reviewingordone, in id order (p.Specsis already sorted). An unknown id is areturned error naming
forge status.Spec.CriterionGaps()(decision 3):<ID>: <AC> has no task in <relative path>and<ID>: <AC> has no evidence in <relative path>, the path relative to therepository root with forward slashes. When the selected specs have
criteria and no gaps,
<N> specs checked, every criterion is covered;when nothing is selected,
no spec is building or reviewing yet; nothing to check.1when a reported gap is a failure (decision 5) on a specthat is
reviewingordone;0otherwise, including a gap on animplementingorblockedspec, where the review does not exist yet. Aload failure prints
forge: ...to stderr and returns1, likecmdValidate.Discards: reporting evidence gaps while a spec is still
implementing(every criterion of every in-flight spec would read as uncovered before the
reviewer writes anything); a
--jsonor--quietflag (no caller needs oneyet); leaving the report to
forge validatealone (the reviewer needs onefocused, read-only command before archiving).
3. Criterion-to-task and criterion-to-evidence coverage is derived once, in
internal/project, and bothforge checkandforge validateconsume it.internal/project/project.gogains:Matching is a bounded, case-insensitive literal token: a criterion has a
task when
tasks.mdcontains\bAC<n>\b, soAC1never matchesAC10,AC1xorAC1-. Evidence is read from the## Acceptance criteriasectionof
review.md(doc.Section, the SPEC-018 fixed English heading) and notfrom the whole file, so a mention under
## Notesor## Blocking problemsis not evidence. Applicability follows when each fileis the record: a task gap is reported from
implementingon; an evidencegap from
reviewingon (the statesvalidate.checkArtifactsrequires thefiles for). Criteria keep declaration order and a task gap precedes an
evidence gap. A missing or unreadable file reports the applicable kind for
every criterion: the file names nothing.
internal/validate/validate.gogainsfunc checkCriteriaCoverage(p *project.Project, s *project.Spec, add func(Severity, string, string, ...any)),called from
Runright aftercheckArtifacts. Each gap becomes aFindingwhose severity is decision 5's, with the same two messages as
forge check(
<AC> has no task in <rel>/<AC> has no evidence in <rel>). The existingcheckCoverage(parent to child) is untouched; the new name keeps themapart. Discards: a second token matcher in
internal/cliand a second statetable in
internal/validate(the drift SPEC-018 removed elsewhere); readingthe whole
review.md(the template's example rows and any prose would countas evidence); folding
checkArtifactsinto this.4. The shipped templates stop carrying tokens the check reads as real, and
the docs state the rule.
Following SPEC-019: a template must not ship a literal
AC<digit>, or afresh file copied from it would read as covered.
kit/machine/templates/review.mdmoves its example rows into an HTMLcomment and writes the placeholder as
ACn, leaving the live| Criterion | Result | Evidence |table header only.kit/machine/templates/tasks.mdtells the author to name the criteria eachphase moves, with the placeholder
moves: <criterion ids>and no digit.kit/machine/templates/spec.md's Acceptance criteria guidance names thethree evidence kinds.
docs/cli.mdgains aforge check [id]section andchanges the
forge approveandforge validateparagraphs to the new gates(AC5);
docs/workflow.md's## Acceptance criterianamesforge checkbeside the existing human rule;
kit/machine/roles/reviewer.mdrunsforge check <id>before writing the review;internal/cli/cli.go's usagelists
forge check. Discards: leaving the review template'sAC1/AC2rows (a reviewer who copies it and forgets to edit would look covered — the
SPEC-019 bug one artifact over); deleting the guidance instead of commenting
it (the section becomes unexplained).
5. Coverage is forward-only, and the
done-state error keys on evidence.Spec.CriterionGaps()returns nothing for a spec whose## Existing statesection is empty. That section is the marker SPEC-015 introduced for a spec
written under the current workflow, and decision 0004 keeps a delivered spec
without it as history that is not re-judged. On this repository that leaves
SPEC-015 alone: its
review.mdcovers every criterion, so it never errors.In flight (
implementing,blocked,reviewing) a missing task and amissing evidence line are both
Warnings, andforge checkprints both.At
done, a missing evidence line is anErrorinforge validateand thenon-zero exit in
forge check, becausereview.mdis the durable proof; amissing task with evidence present stays a
Warning, because the criterionis settled and the delivered
tasks.mdthat never named criteria arehistory.
forge checkstill prints that task gap (AC2) but counts only anevidence gap as a failure.
Discards: erroring on a missing task at
done(every delivered spec whosetasks.mdpredates this rule would failforge validate, and the only fixis rewriting history, which decision 0004 rejects); gating on a new
frontmatter key (a second thing to keep in sync, no more precise than the
section the workflow already requires); skipping
forge checkfordelivered specs (it reports them, it just does not fail them on a task gap).
OQ1 asks whether the stricter reading of AC4 is intended.
Interfaces other specs build against
project.Criterion.Verifiable() boolis the criterion rule; a laterreader of criteria uses it instead of restating the anchors.
project.CriterionGap{Criterion, File, Kind}andproject.Spec.CriterionGaps() []CriterionGapare the coveragederivation, including the forward-only gate.
forge checkandinternal/validateare the callers; a later spec adds a consumer, not asecond matcher.
forge check [id]is read-only and exits non-zero only for a failure gap(decision 5) on a
reviewingordonespec.forge approvestill setsapproved_byandcontract_hash; the new refusal runs before either,beside the open-questions refusal.
forge validategains criterion coverage;--quiet(errors only) hidesthe task warnings with no change.
no new state and no new key.
Tests
internal/project/project_test.go:TestCriterion_Verifiable— a table:a backticked command, a
test/TestName, and areturns/refusescriterion are verifiable;
Works well,The system is fastand a textwith no anchor are not;
AC10in the text does not changeAC1.internal/project/project_test.go:TestCriterionGaps_MatchesBoundedTokens— a
reviewingspec withAC1..AC3:tasks.mdnamingAC1, AC3and areview.mdAcceptance criteria row forAC1yield gapsAC2/task,AC2/evidence andAC3/evidence;AC10andAC1xin either file matchnothing.
internal/project/project_test.go:TestCriterionGaps_AppliesByState— atask gap from
implementingon, an evidence gap only fromreviewing; aplanning/proposedspec yields none, and a spec without## Existing stateyields none at every state.internal/validate/validate_test.go:TestRun_CriterionCoverageWarnsAndErrors— an
implementingspec missing a task is aWarning; adonespecmissing an evidence line is an
Error; adonespec missing only a taskis a
Warning.internal/cli/cli_test.go:TestApprove_RefusesUnverifiableCriterion— witha non-empty contract and
None.questions, a criterionThe UI is fastmakes
forge approveexit 1 and name the id; rewriting it with abackticked command approves the spec to
planningwithcontract_hashset.
internal/cli/cli_test.go:TestCheck_ReportsUncoveredCriteria— adonespec whose
tasks.mdandreview.mdomit a criterion:forge checkprints the criterion and the relative file in one
no taskline and oneno evidenceline, and exits 1.internal/cli/cli_test.go:TestCheck_ImplementingGapExitsZero— animplementingspec with a task gap prints it and exits 0.internal/cli/cli_test.go:TestCheck_IsDeterministicAndWritesNothing— tworuns are byte-identical and the tree hash plus
git status --porcelainare unchanged.
internal/cli/machine_test.go:TestTemplates_CarryNoRealCriterionId—forge template tasksandforge template reviewcontain no\bAC\d+\b(the templates are read through
kit.Template).internal/cli/cli_test.go:TestDocPages_DocumentTheCriterionRule—docs/cli.mdhas aforge checksection and theforge approvesectionstates the rule; no page restates the state machine (the SPEC-018 scan
extended).
Out of scope of this contract
how they are parsed (
project.criterionRe).forge approve:forge checkandforge validatereport coverage, not verifiability.forge archiveon coverage; the review is whereforge checkisrun.
delivered specs'
tasks.md(OQ1).--json) or CI wiring beyond the existingforge validateexit code..forge/specs/or.forge/decisions/records.