Skip to content

Archiving two changes that MODIFY the same requirement silently drops scenarios (distinct from #1112) #1246

Description

@stanleykao72

Related to #1112, but a distinct (opposite) failure mode. #1112 covers MODIFIED against a header that does not exist in the base spec → openspec archive aborts loudly (not found). This issue is the inverse: the header does exist and is MODIFIED by two changes, so archive succeeds but silently overwrites the earlier change's scenarios. #1112's validate-time fix does not address this case.

Summary

When two active changes both list the same ### Requirement: under ## MODIFIED Requirements and are archived sequentially, the second archive overwrites the first — the earlier change's scenarios are silently dropped from the canonical spec. No warning, no diff, no conflict marker; archive reports success.

Root cause

buildUpdatedSpec in src/core/specs-apply.ts (around L296) applies MODIFIED as a whole-block replace keyed by requirement name (nameToBlock.set(key, mod)). The delta carries the change author's view of the requirement at authoring time; when applied, it replaces the entire requirement block, discarding any sibling scenarios another change added in between. validateChangeDeltaSpecs checks only intra-change consistency — there is no base-fingerprint / cross-change drift detection, and archive.ts has no conflict gate.

Reproduction

  1. Change A: MODIFIED requirement R, adds scenario S_A.
  2. Change B (branched from the pre-A spec): MODIFIED the same R, adds scenario S_B.
  3. Archive A → canonical spec has S_A (plus existing scenarios).
  4. Archive B → buildUpdatedSpec whole-block replaces R with B's version → S_A is gone, silently.

Change B's delta never knew S_A existed, so replacing the whole block drops it.

Fix options (RFC drafted)

Happy to open a PR in whichever direction you prefer. Full RFC available on request.

Activity

  1. HerbertGao commented on Jul 11, 2026

    @HerbertGao

    Heads-up on the scenario-drift gate that guards this case: findMissingCurrentScenarios in src/core/specs-apply.ts has a duplicate-scenario-name blind spot, so the silent drop this issue describes can still slip through even with the gate in place.

    The gate stores the incoming (MODIFIED) scenario names in a Set and flags any current scenario whose name isn't in it:

    function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[] {
      const incomingScenarioNames = new Set(parseScenarioBlocks(incoming.raw).map((s) => s.name));
      return parseScenarioBlocks(current.raw)
        .filter((s) => !incomingScenarioNames.has(s.name))   // set membership ignores multiplicity
        .map((s) => s.name);
    }

    Because it's set membership, if a requirement has two scenarios with the same name and a MODIFIED block keeps only one, both current copies still "match" the single incoming name, missing is empty, and the second copy is dropped with no abort.

    Minimal repro (single change)

    Current canonical spec:

    ### Requirement: Login
    The system SHALL authenticate.
    
    #### Scenario: Validate
    - **WHEN** input is empty
    - **THEN** reject
    
    #### Scenario: Validate
    - **WHEN** input is malformed
    - **THEN** reject

    A change whose ## MODIFIED Requirements keeps only one #### Scenario: Validate → archive succeeds and the malformed-input scenario is gone. (Same failure mode as the two-change flow above, just surfaced through the gate's name-set.)

    Suggested tweak (whichever fix direction you take)

    Compare with multiplicity instead of set membership, so duplicates can't cancel out:

    function findMissingCurrentScenarios(current, incoming): string[] {
      const remaining = new Map<string, number>();
      for (const s of parseScenarioBlocks(incoming.raw))
        remaining.set(s.name, (remaining.get(s.name) ?? 0) + 1);
      const missing: string[] = [];
      for (const s of parseScenarioBlocks(current.raw)) {
        const n = remaining.get(s.name) ?? 0;
        if (n > 0) remaining.set(s.name, n - 1);
        else missing.push(s.name);
      }
      return missing;
    }

    (Arguably duplicate scenario names within one requirement also deserve a validate warning in their own right.)

    Surfaced while hardening a downstream fork; language-agnostic, reproduces on upstream with English-only specs.

  2. clay-good commented on Jul 22, 2026

    @clay-good
    Collaborator

    Closing as fixed by #1391 (commit 470f572). Archive now runs a multiplicity-aware scenario-drift check (findMissingCurrentScenarios in src/core/specs-apply.ts): if the current spec contains scenarios that the incoming MODIFIED block does not, the archive aborts before touching any file.

    Verified with your exact repro on current main. Archiving change A succeeds; archiving change B fails with:

    auth MODIFIED failed for header "### Requirement: Login" - current spec contains scenario(s) not present in the modified block: "S_A". Refresh the change spec before archiving to avoid dropping scenarios.
    Aborted. No files were changed.
    

    and S_A stays in the canonical spec. That is your option A, stop-the-bleeding edition; scenario-level merge (option B) would be its own feature if demand appears. The fix is on main and ships with the next release. Thanks for the unusually precise root-cause analysis.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions