Skip to content

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

@luigileap

Description

@luigileap

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions