Repository navigation
validate --strict passes a Purpose that is still the archive TBD placeholder #1670
Description
Description
When a delta introduces a new capability without a usable ## Purpose,
openspec archive writes a placeholder into the main spec it creates:
## Purpose
TBD - created by archiving change c1. Update Purpose after archive.Nothing reports it afterwards. openspec validate --specs --strict passes the
spec, so a capability can carry an unwritten Purpose indefinitely while every
command reports success.
The check that exists to catch a Purpose nobody wrote is the 50-character
brevity floor (MIN_PURPOSE_LENGTH). The placeholder's fixed text alone is 65
characters before the change name is interpolated, so it clears that floor for
every change name. The one rule meant to catch a thin Purpose is satisfied by
the exact string that means nobody wrote one.
The result is inverted: a Purpose reading Does stuff. fails --strict, while a
Purpose saying nothing at all passes.
Steps to Reproduce
With CLI 1.9.0, in an empty directory containing openspec/config.yaml
(schema: spec-driven):
-
openspec new change c1 -
Write
openspec/changes/c1/specs/widgets/spec.mdwith## ADDED Requirements
and no## Purpose(the delta introduces a new capability):## ADDED Requirements ### Requirement: Retries are bounded The system SHALL stop retrying a delivery after the configured budget. #### Scenario: Budget exhausted - **WHEN** the budget is exhausted - **THEN** the delivery is abandoned
-
Add a
proposal.mdandtasks.md, thenopenspec archive c1 --yes -
openspec validate --specs --strict
Expected Behavior
Step 4 reports that widgets still carries the placeholder Purpose.
Actual Behavior
$ cat openspec/specs/widgets/spec.md
## Purpose
TBD - created by archiving change c1. Update Purpose after archive.
$ openspec validate --specs --strict
✓ spec/widgets
Totals: 1 passed, 0 failed (1 items)
$ echo $?
0
Replacing that Purpose with the authored-but-terse Does stuff. and re-running
the same command gives:
✗ spec/widgets
Totals: 0 passed, 1 failed (1 items)
$ echo $?
1
So the spec that says nothing passes and the spec that says a little fails.
Why this is not covered by #1431
#1431 fixed the case where a
delta does carry a ## Purpose — archive now uses it instead of the
placeholder — and updated the schema, templates and sync-specs skill to tell
authors and agents to write one.
The reproduction above is the remaining case: the delta has no ## Purpose at
all, so archive still writes the placeholder, exactly as before, and nothing
reports it. Every remedy on that path is an instruction rather than a check.
That matters because #369 —
closed by #1431 — reported precisely that instructions do not hold:
I tried to create specs that requires the agent to fill the purpose section
after archiving the proposals, but they just constantly forgetting this.
I ran into this on a project whose four main specs have carried the placeholder
since July. None of the four deltas had a ## Purpose, so #1431 would not have
prevented any of them, and validate --specs --strict reports 8 passed, 0 failed
with all four in place.
The project already treats this as something to fix
Three places tell an author or agent to replace the placeholder:
| Where | Says |
|---|---|
schemas/spec-driven/schema.yaml |
"To change an existing capability's Purpose — including a leftover TBD placeholder — edit openspec/specs/<capability-path>/spec.md directly." |
src/core/templates/workflows/sync-specs.ts |
report "Any new main spec left with a TBD Purpose placeholder, so it gets written now rather than lingering" |
openspec/specs/cli-archive/spec.md |
"write the TBD placeholder Purpose naming the change to update after archive" |
The word leftover, and the concern about lingering, are already yours. What is
missing is anything that tells you it is still there — which is why it can linger
for months without anyone noticing. A silent check is indistinguishable from a
clean run.
One framing worth putting explicitly
The #1431 description notes, under "One deliberate trade":
Before this PR, "archive succeeded" implied "
validate --specs --strict
passes", because the placeholder is always over the 50-character minimum.
I read that same fact the other way round: the placeholder clearing the floor is
not a property worth preserving, it is the mechanism by which the placeholder
survives. That reframe is really the whole issue, and it is a judgement call
rather than a defect — so it seemed worth stating rather than assuming.
Suggested fix
Report it during validation rather than changing what archive writes — the
placeholder is a useful marker at the moment it is created, it just needs to be
reported once it has outlived that moment.
- Severity: a warning. Strict mode already means "warnings fail", so a warning
is silent by default and fails--strict. Projects carrying placeholders on
disk keep validating until they opt into the stricter gate. An error would
break them on upgrade for a documentation defect. - Detection kept narrow, so the warning stays worth reading: the sentence
archive generates, recognised through the same definition that writes it; and
otherwise only aTBDopening the Purpose, which is what an agent writes when
told to leave "a brief TBD placeholder". ATBDinside a sentence — "the retry
budget is TBD pending benchmarks" — is authored prose and is left alone. - Archive unaffected, since it validates rebuilt specs without
--strict.
This mirrors src/core/validation/task-numbering.ts, which is the same shape: a
pure module under src/core/validation/ whose findings become warnings.
I have this implemented and tested and am happy to open a PR — I wanted to raise
the design question first, since #369 was closed recently and you may see the
remaining case differently.
Open questions for you:
- Warning versus error. I went with warning for the upgrade-safety reason above,
but it is your call. - Should a
TODOmarker count as well asTBD? Nothing produces one today, so
I left it out.
Environment
- OpenSpec CLI 1.9.0
- Node 26, macOS
Activity
test(validate): make every placeholder guard load-bearing
fix(validate): name the placeholder's line, not the prose above it
fix(validate): widen the placeholder check to TODO and read fences as…
fix(validate): read the marker boundary in any script, not just ASCII
fix(validate): report a Purpose left as the archive placeholder (Fiss…