Repository navigation
validate: MODIFIED/REMOVED/RENAMED-from headers that don't exist in base spec aren't caught until archive (proposal: opt-in cross-change MODIFIED) #1112
Description
Symptom
If a change delta references a ### Requirement: … header in ## MODIFIED Requirements, ## REMOVED Requirements, or ## RENAMED Requirements (FROM side) whose header does not appear in the base spec, openspec validate <change> returns Change is valid — even with --strict. The bug only surfaces later, at openspec archive time, as:
<cap> MODIFIED failed for header "### Requirement: …" - not found
Aborted. No files were changed.
By then the implementing PR has typically shipped (days or weeks earlier). The session that authored the spec is gone. Recovering requires forensics + cross-change unblock work.
Real-world reproductions
I hit this twice within 6 days on the same project:
Case 1 — authoring misclassification. An agent wrote ## MODIFIED Requirements followed by a brand-new requirement title that doesn't exist in the base spec. Should have been ADDED. The agent's mental model was "I'm modifying behavior of an existing feature" (true — the implementation extended an existing component); OpenSpec's semantics is "MODIFIED replaces a Requirement whose ### Requirement: header exactly matches one in the base spec" (whitespace-insensitive). The mismatch sat undetected from 2026-05-15 to 2026-05-21.
Case 2 — cross-change MODIFIED, sister change not yet archived. Change B wanted to MODIFY a requirement that Change A introduced. Change A's implementing PR had already merged and shipped to production, but Change A itself was never archived — so the requirement lived in openspec/changes/<A>/specs/<cap>/spec.md, not in canonical openspec/specs/<cap>/spec.md. Change B's agent wrote ## MODIFIED Requirements against that. Archive aborted weeks later.
Both cases share the same root cause: agents conflate "MODIFIED = extending existing behavior" with OpenSpec's actual semantics "MODIFIED = replace canonical Requirement by exact header match".
Minimal reproduction
mkdir -p openspec/changes/test-bug/specs/foo
cat > openspec/changes/test-bug/specs/foo/spec.md <<'EOF'
## MODIFIED Requirements
### Requirement: Brand-new requirement that base does not have
The system SHALL do new things.
#### Scenario: foo
- **WHEN** user does X
- **THEN** result is Y
EOF
# proposal.md etc. omitted for brevity
openspec validate test-bug --strict
# Output: "Change 'test-bug' is valid" ← FALSE POSITIVE
openspec archive test-bug --yes
# Output: "foo MODIFIED failed for header ... - not found. Aborted."
# (only NOW does the bug surface)Proposal
Move the existing archive-time check into Validator.validateChangeDeltaSpecs so it fires at write time too — with a deliberate opt-in for the legitimate cross-change case.
Default behavior (no flag)
openspec validate <change> errors when MODIFIED/REMOVED/RENAMED-from references a header that doesn't exist in canonical openspec/specs/<cap>/spec.md. Matches archive-time strictness.
Opt-in: --accept-cross-change-base
When set, the check also looks in sister-pending changes at openspec/changes/<other>/specs/<cap>/spec.md. If the header appears there, validate passes. The user has consciously opted into cross-change MODIFIED authoring.
openspec archive remains strict regardless of this flag. Archive only accepts canonical-base references. The user opting into --accept-cross-change-base at write time has committed to either (a) archiving the sister change first, or (b) folding this change into it, before this change can itself archive.
Why this split
Encoding the existing-but-unenforced doc rule: openspec instructions specs already says "Locate the existing requirement in openspec/specs/<capability>/spec.md". The rule's just not checked. Move the check earlier. The flag accommodates the one legitimate cross-change pattern (sister change is in flight; you're extending it) without weakening archive-time semantics where the canonical record matters.
Implementation
PR follows shortly — keeps the change small (one new helper method on Validator, one new CLI flag, no breaking API changes; legacy new Validator(true) constructor preserved via overload). 9 new tests added; full existing suite (1511 tests) still green.
Happy to discuss flag name (--accept-cross-change-base is descriptive but verbose). Other contenders: --allow-pending-base, --allow-cross-change, --accept-sister-deltas. I'd take any direction here.
Activity
docs(proposal): add prevent-silent-spec-drop change
docs(proposal): add prevent-silent-spec-drop change
docs(proposal): add prevent-silent-spec-drop change
docs(proposal): add prevent-silent-spec-drop change
docs(proposal): add prevent-silent-spec-drop change
docs(proposal): add prevent-silent-spec-drop change
docs(proposal): add prevent-silent-spec-drop change
docs(proposal): add prevent-silent-spec-drop change
ryandemelo commentedon Aug 19, 2026
Case 2 here (Change B MODIFYing a requirement Change A introduced but hasn't archived) is the same shape I ran into from the other side, working on cross-change overlap reporting for #1669 — so a note in case it's useful for scoping the fix.
The two implementations that disagree are
validator.ts:607(if (!current) continue;— a MODIFIED whose target is missing validates clean) andspecs-apply.ts:454(MODIFIED failed ... - not found). Anything else that needs to answer "does this delta apply?" — overlap reporting, a--dry-run, thediffcommand in #427 — becomes a third implementation of the same question unless the check lands as one function bothvalidateandbuildUpdatedSpeccall.I tried to answer it a second time and got it wrong twice, verified against real archive runs:
specs-apply.tshas several branches that treat a repeat as already-synced rather than as a collision, andRENAMED's version of that tests the counterpart header's presence, so a check reasoning about one header at a time gets renames wrong in both directions. I've written that up in more detail on #1113, which looks like the right home for the fix.Not proposing anything competing here — #1113 is open for this and @ixxie has offered to finish it.
ixxie commentedon Aug 19, 2026
@ryandemelo you may wanna have a look at #1683 and the associated PR; I came to the design trying to solve CI issues but it should make #1669 a lot simpler, at least in the long term.
Of course since my proposal is an opt-in mode with migrations, I guess it would be a bit complicated during the intermediate dual support time. But Im convinced explicit state makes everything easier.
feat(lifecycle): experimental `lifecycle: status` mode — state as data
feat(lifecycle): experimental `lifecycle: status` mode — state as data
feat(validate): report the deltas archive would refuse (Fission-AI#1710)
ryandemelo commentedon Sep 7, 2026
Thanks @ixxie — read it properly, and replied over on #1683 since the argument is with II and IV rather than with anything on this issue. Short version for anyone following this thread: I think IV is the decision that bears on #1112, and it states the shared-implementation property for
sync --checkand the fold, which is a different pair from thevalidate/buildUpdatedSpecsplit this issue is about. Same principle, neither one closes the other.jean-sturacci commentedon Oct 1, 2026
We hit both of your cases on a 73-change cross-repo project and ended up building an external deny-gate for them. Since #1918 was closed into this issue with the remaining question stated as "making validate fail on a lone typo without blocking valid stacked changes", here is how we discriminate the two — in case the shape is useful, not as a request.
The discriminator we use: resolve the absent target against the declared predecessor's ADDED requirements.
Our changes carry a
blocked_by:list (a project-local field — OpenSpec has no equivalent, which is the obvious limit of this report). The gate, run onarchive:## MODIFIEDheaders out of the change's deltas.openspec/specs/<cap>/spec.md.## ADDED Requirementsin each declared predecessor's delta.So the signal that separates them is not anything about the header — it is whether the author declared the dependency. A typo declares nothing.
Two things we got wrong first, both worth more than the mechanism:
🔴 Our first version took 27.2 seconds. A
PreToolUsehook that exceeds the harness timeout fails silently, so for a deny gate that means the archive proceeds unguarded — the exact false all-clear it existed to remove. If you do put this invalidate, the in-memory merge is already paid for by #1710, but the predecessor walk is not. Ours now measures 0.8–3.9s over 168 archived changes, and we plan against the worst case, never the best.🔴 Matching must be anchored. An archived
2026-08-11-<id>must not satisfy ablocked_byof a different id that happens to be a suffix of it.A second gate, because the first answers the wrong question for a reader. The deny gate judges only the change being archived. We also compute, across the whole in-flight set, which other changes declare a requirement this delta also declares under
MODIFIED/REMOVED/RENAMED— the three body-reproducing sections. That one is an ask, never a deny: two in-flight changes touching one requirement is legitimate and, with most of ours sharing a capability, routine. The resolution is ordering, not exclusion, and a gate that blocks the normal path is one people route around.Both reuse the same heading extractor and the same scope expression byte-for-byte, because two mechanisms parsing the same text differently is worse than one — both keep running and only one is right.
Why we think INFO alone was not enough for us, stated as experience rather than as a disagreement with the call made in #1918: a MODIFIED requirement reproduces its whole body, so when two changes modify one requirement and the second archives against the pre-rebase body, archive reports success while reverting everything the first added. We measured one such pair where the scenario set of the second was a strict superset of the current spec's — it added one scenario and dropped none — so the 1.8.0 scenario-drop guard correctly did not fire. The loss would have been confined to prose inside scenarios both deltas kept, which nothing compares. That case is invisible to any check keyed on scenario names.
Happy to answer questions; we are not asking for the
blocked_byfield itself, only offering the discrimination rule in case it saves someone the two wrong turns above.ryandemelo commentedon Oct 2, 2026
@jean-sturacci this is the most useful thing on this issue. Rather than agree with it I went and checked the part that bears on my own work against current
main, then measured it here, so the below is confirmation and one correction to my own first attempt at measuring it.The guard is name-keyed, exactly as you say.
Two call sites, the archive-time one at
specs-apply.ts:542and the validate-time one atvalidator.ts:765. Both calldiffScenarioNames, whose first two lines are:Names only, and nothing anywhere in
src/core/compares one scenario body against another. The apply path then doesnameToBlock.set(key, mod)atspecs-apply.ts:555, replacing the whole block. The only thing between the guard and that line is anormalizeBlockRawcomparison at552, and it exists to decide whether a write happened, not to warn that one did. So your case lands precisely where you said: scenario set a superset, guard silent, requirement statement and every kept scenario's body replaced wholesale.How often the shape occurs in this repository.
85 archived changes, 122 delta spec files, 93 MODIFIED blocks, 52 distinct (capability, requirement) pairs modified.
One named case, since an aggregate is easy to wave away. Four changes archived together on 2025-10-14 all modified
cli-init :: Slash Command Configuration, and all four keep the scenario "Generating slash commands for OpenCode". Two of them disagree on the path inside it,.opencode/command/against.opencode/commands/. One of those is what got written and nothing said a choice had been made.What I did not find, which matters as much.
I also wrote the stronger test, because "the bodies differ" is not the same claim as "content was lost". For each archived MODIFIED block: reconstruct the main spec as it stood at the parent of that change's archive commit, walk the full git history of that requirement's statement text, and flag any delta whose statement matches a value that appeared earlier in that history than the base did. That is a revert to a stale base, as opposed to an author shortening something on purpose.
Zero hits across 84 blocks.
My first pass was the easy measurement, "did kept prose get shorter", and it reported 14 cases. Every one I opened was either a batch artifact, where I had compared three deltas from a single archive commit against the same parent, or a deliberate rewrite. That is the number I would have posted had I stopped there, and it would have been wrong.
I read the zero as supporting your framing rather than weakening it. Whether a body difference is intent or an accident is not decidable from the artifacts. The tool cannot tell, and it never asks. That is the same conclusion I reached from the other direction measuring scenario omissions: the omission itself carries no signal about whether the author meant it, so the marker has to be authored.
On
blocked_by: the field exists as a proposal.True of shipped OpenSpec, not of the queue. The open
add-change-stacking-awarenesschange proposesdependsOnas change metadata, withdependsOnas the source of truth for archive ordering, alongsideprovides,requires,touchesandparent. Your discriminator plugs into that field directly, and your report is the strongest argument for it so far because it is a measured account of what goes wrong without it. Worth saying so there if you have the appetite.Your anchored-matching warning applies to it immediately:
dependsOnwill hold change ids, archived ids carry a date prefix, and a suffix match would let2026-08-11-foosatisfy a dependency on a different id ending infoo.Three checks, not one.
Worth separating, because this issue now carries all three and they have different answers.
I am not proposing 3 here. It needs a body comparison that does not exist anywhere in the codebase, and after the 27.2 second story I would rather see the cost measured before anyone designs the check than after.
On the timeout, taking the general lesson.
The asymmetry is the part worth keeping: a report that is slow is just slow, a gate that is slow is a false all-clear. It changed how I wrote up a static scan I have been working on elsewhere in the tool. The cheap prefilter is now a fixed decision with the measured worst case beside it, rather than an implementation detail someone later has to rediscover the reason for.
fidelix commentedon Oct 6, 2026
Capability-path form of the same split. Main has
scheduling/dispatch. The change puts## MODIFIED RequirementsforAssign a technicianunderspecs/scheduling/dispatchh/spec.md(the directory name is mistyped).openspec validate add-region-filter --strictexits 0. The only finding is this INFO line:openspec archive add-region-filter --yesexits 1:Expected:
validate --strictshould fail with an error when aMODIFIEDorREMOVEDdelta targets a capability or requirement that is not in main, consistent with archive. This is the lone typo left open when #1918 was closed into this issue: the capability directory does not exist, rather than a requirement header missing inside an existing spec.Environment: OpenSpec 1.14.1 (
npx -y @fission-ai/openspec@1.14.1 --no-color), Node v24.21.0, macOS (Darwin 25.6.0, arm64). Same INFO line and same exit codes on 1.12.0. I ran the script twice from scratch on each version. Both runs on a version matched.Impact: Two branches editing the same capability in parallel is common.
validate --strictis the check that runs on the branch, and it stays green for a mistyped capability path. Archive is the first command that refuses the delta.Repro
Actual output (1.14.1, trimmed)
archive add-dispatch --yesexits 0. Main specs areopenspec/specs/.gitkeepandopenspec/specs/scheduling/dispatch/spec.md. Then: