Skip to content

Pre-archive drift and overlap checking for parallel changes: working tool, offer to contribute #1387

Open

Description

@outofcoffee

Hi. Thank you for OpenSpec. We appreciate its flexibility and design, and it has worked super well for us.

After using it for a while, we hit the concurrent-modification problem described in openspec-parallel-merge-plan.md: two open changes modify the same requirement, and the second one to archive either conflicts late or, in the rebase-then-archive ordering, silently overwrites the first author's edit. The root cause is that a delta records only the new text of a MODIFIED requirement, with no record of the base it was derived from. The v1.6.0 archive guardrail (#1340) helps at archive time, but by then the second author has already worked against stale context, and overlap between open changes is not visible at all.

To help us deal with this, we built a small companion CLI that does the detection part today, before archive and in CI. We'd like to offer the ideas (and code, if useful) upstream.

concord: npm i -g @lucinate-ai/concord (Apache-2.0, TypeScript, parsers mirror the v1.6.0 header patterns exactly)

concord check verifies every MODIFIED/REMOVED/RENAMED target in every open change against the base branch and reports four finding kinds:

  • drift: the requirement changed on main after the branch diverged, so archiving would discard that change (shown as a redline)
  • removed-upstream: the target was deleted or renamed on main
  • target-missing: the name matches nothing, usually a typo, so archive can't apply the entry
  • name-collision: an ADDED or RENAMED-to name already exists

concord overlap lists any requirement claimed by more than one open change, so two authors find out on day one instead of at archive time. Exit codes are 0/1/2 for CI use, and both commands take --json.

One implementation detail may be of interest: no stored state is needed for the common case. For a PR branch, the base a delta was derived from is the requirement's text at merge-base(HEAD, main), and the landing target is the text at the main tip. Both reconstruct from git history. Blocks are canonicalised (line endings, trailing whitespace, blank-line runs) before hashing, so reformatting never registers as drift. Rebasing or merging main advances the merge-base, which clears the finding at the point where the author has re-derived their block anyway.

How this relates to the plan's phases, as we read them:

  • The git reconstruction gives Phase-0-style detection before archive, on brownfield repos, with no format changes.
  • Your planned meta.json base snapshots cover exactly what git history cannot reconstruct (hand-edited deltas, provenance across an archive). We'd switch to them as the preferred source as soon as they exist.
  • A Phase-1 sync/rebase (structured three-way merge keyed on requirement and scenario) is where we want to go next. Scenario-keyed merging would auto-resolve the most common collision: two changes editing different scenarios of one requirement. Happy to compare notes or pool effort there.

Questions:

  1. Would you take some or all of this upstream (as subcommands or a validation pass), or would you rather it stayed a companion tool?
  2. Any objection to us tracking your delta grammar? If the header patterns are going to change (for example the Phase-3 stable-ID work), a pointer to the direction would help us stay compatible.
  3. Is the meta.json snapshot schema in the plan settled enough to build against?

Happy to open PRs against Phase 0/1 if that's welcome. We built this so teams can adopt OpenSpec with confidence, and we'd be glad to see any of it absorbed upstream.

Activity

  1. ryandemelo commented on Aug 22, 2026

    @ryandemelo
    Contributor

    Coming to this a month late, sorry. Flagging first that the direction has independent demand: I filed #1669 and opened #1698, which does the concord overlap half — requirements claimed by more than one active change, reported by validate, advisory only, never changes the exit code. Written without knowing this issue existed. Your offer predates it by a month so please read that as corroboration, not me racing you to it.

    The bullet I'd push hardest on is your third one, the scenario-keyed merge. Some things from inside the codebase that you can't really see from a companion tool, and which I think make it cheaper than the plan assumes.

    The scenario-level substrate is already there. parseScenarioBlocks (src/core/parsers/requirement-blocks.ts:396) splits a requirement's raw into {name, raw} scenario blocks today — fence-aware, so a #### inside a fenced example is masked out, and multiplicity-aware. It's been hardened repeatedly against the silent-loss class in #1246, #1252 and #1391 among them, and findMissingCurrentScenarios (:341) is already shared by archive and validate on purpose so the two can't disagree about what counts as a dropped scenario. It's private, not missing. A scenario-keyed merge needs it exported, not written.

    Which is why I think Phase 2 is unnecessary rather than just deferrable. Phase 2 wants new delta syntax (## ADDED Scenarios / ## MODIFIED Scenarios), stable scenario IDs, and a one-time rewrite of every in-flight delta, all to reach the stated outcome that "most concurrent updates become commutative". But scenarios are already name-addressable and the parser above already resolve them multiplicity-aware, so a scenario-keyed three-way merge gets you that outcome on the current format with no new syntax and no migration at all.

    Same argument weakens Phase 0, honestly. You offered to switch to meta.json snapshots as the preferred base source once they exist — I'd at least consider the opposite. The plan itself lists "what is the graceful failure mode if contributors delete the meta.json file?" as an open question, and a git-reconstructed base just doesn't have that failure mode. The gap you named is real, hand-edited deltas and provenance across an archive genuinely aren't reconstructable, but it's narrow, and accepting degraded detection there might be cheaper than carrying a second source of truth plus a backfill migration.

    One data point that landed after you filed, which I think strengthens your case more than drift does. #1697: a MODIFIED block can't rename a scenario, because findMissingCurrentScenarios compares the incoming block against the current spec — two-way — so a rename is indistinguishable from a deletion by construction. In that thread @johnmcarbajal replayed the comparison across a real repo's history: 29 of 75 archived changes carrying a MODIFIED block, 39%, would be blocked under 1.9.0. With a base that ambiguity just goes away — a scenario present in BASE, absent by name in OURS, with an unmatched near-identical body under a new name, is a rename. Same similarity heuristic git uses for -M. And it fails safe, a missed rename degrades to exactly today's behaviour. So the base you're already reconstructing for drift fixes a 39%-incidence blocker as a byproduct.

    Your target-missing finding also maps onto a live bug. validate only compares against the main spec when mainSpecsDir is set and the delta has MODIFIED blocks (src/core/validation/validator.ts:312), and then if (!current) continue (:607) skips a MODIFIED whose target is missing entirely. Meanwhile specs-apply.ts throws at eight sites before it'll merge — RENAMED source missing (:400), RENAMED target exists (:403), REMOVED missing (:435), MODIFIED missing (:454), header mismatch (:460), scenario drop (:466), ADDED exists (:491). validate covers exactly one of those eight. That gap is #1112, which has two reproductions where it surfaced weeks after the implementing PR had already shipped.

    On your question 2, one detail worth checking concord's parsers against: SCENARIO_HEADER is /^####\s+/ (src/core/parsers/requirement-text.ts:30). Any level-4 header counts as a scenario, not only #### Scenario: — a parser keyed on the literal prefix will under-count.

    On question 1 I should be clear about standing — I'm a contributor here, not a maintainer, so I can't tell you whether any of this gets absorbed. What I'd say is that size is the thing most likely to sink it. The merge engine is a pure function, (base, ours, theirs) → merged | conflicts, no I/O, no git, no CLI. Split on that boundary and the first piece is just the exported parser plus a table-tested pure merge with zero behaviour change to any command. Git base resolution is a second piece and can follow the null-on-failure probe pattern that's already in src/core/store/git.ts. Rename-aware validate is a third and that one alone closes #1697. Each is useful on its own if the chain stops early.

    Happy to pool effort on the merge engine if you're still up for it.

  2. ryandemelo commented on Aug 22, 2026

    @ryandemelo
    Contributor

    Correcting something I wrote above, on the strength of @johnmcarbajal's replay in #1697.

    I claimed a reconstructed base dissolves the rename problem via content-similarity matching, the way git does -M. Their data says that doesn't hold. They tried to build an objective proxy over the same corpus and it failed — one change produced 6 findings against a single requirement where 2 were renames and 4 were accidents, identical in scenario count, heading and id presence, separable only by reading the prose. And renames are 9 of 26 findings; deliberate deletions are another 7, which a rename operator doesn't touch at all. Their conclusion is that intent has to be declared rather than inferred, and I think that's right.

    What the base does buy is narrower than I said, and worth keeping separate: the stale-base class, 4 of their 26, where the delta was authored before another change added the scenario and neither author ever saw what they were "omitting". No marker can fix that one — the remedy is reconciling against the current base, not declaring intent, and they specifically ask for the two to stay distinguishable. That is the case a merge-base reconstruction answers and nothing else does.

    So I'd revise the pitch: the base is not a way to infer intent, it's a way to tell "I never saw this" apart from "I meant to drop this." Still the piece worth building, just not the one I said it was.

  3. Marzx13 commented on Aug 22, 2026

    @Marzx13
    Contributor

    One current acceptance data point for any shared applicability evaluator (not a proposal for another command-specific checker): I ran the archive matrix on current main (f1b521d) and #1698 (e1b37043) on Windows — 8 fixtures × ordinary/--no-validate × human/JSON, or 32 runs per target, with fresh fixtures each time.

    Four fixtures contain multiple independent root failures: two ADDED collisions; RENAMED-missing + unrelated MODIFIED-missing + ADDED collision; errors in two capabilities; and REMOVED near-miss + unrelated MODIFIED-missing. Each hidden operation also fails when run alone. In all 32 multi-error runs across the two current refs, archive emitted only the first applicability message. All 48 failed target runs preserved the complete fixture hash; JSON remained parseable; valid and already-applied controls passed.

    There is an important opposite case too. Old -> New followed by MODIFIED New succeeds, but a missing-source rename to New followed by MODIFIED New reports only the rename failure. Reporting MODIFIED-not-found as another root cause there would be a cascade caused by the failed rename.

    So the acceptance boundary I would pin for the shared evaluator is: return every independent applicability error in deterministic order, while blocking or suppressing downstream diagnostics whose preconditions depend on a failed operation. Preserve the existing already-synced no-ops and mutation-free failure behavior. This is evidence for the existing design direction, not a request to expand #1698's advisory scope.

  4. fidelix commented on Oct 6, 2026

    @fidelix

    Reproduction of the rebase-then-archive overwrite from the first paragraph. The scenario-name guard from #1246 aborts while a scenario is missing. After that scenario is copied into the delta and the requirement sentence is left stale, archive exits 0 and the earlier sentence is gone. The guard compares scenario names only.

    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). The same script also reproduces on 1.12.0. On 1.12.0 the abort text is shorter: it names Missing skill and does not include the scenario counts. After the scenario is copied, archive still exits 0 and the skill clause is gone. I ran the script twice from scratch on each version. Both runs on a version matched.

    Expected: validate or archive should detect that the MODIFIED block was written against an older copy of the requirement, and refuse or warn, even when every current scenario name is present. One approach is to record a hash of the base requirement text when the delta is created, or to compare the non-scenario body with the current main spec and warn when that body changed.

    Impact: Two branches editing the same capability in parallel is common. The author who archives second is told to copy the new scenario into the delta. Doing that clears the guard, and the other branch's requirement text is replaced with no warning.

    Repro

    #!/usr/bin/env bash
    # Bug: archiving a second MODIFIED delta silently reverts an earlier edit
    # to the same requirement when the scenario names still match.
    set -euo pipefail
    
    OPENSPEC=(npx -y @fission-ai/openspec@1.14.1 --no-color)
    ROOT="$(mktemp -d)"
    cd "$ROOT"
    git init -q
    echo "+ ${OPENSPEC[*]} init --tools none --no-animation ."
    "${OPENSPEC[@]}" init --tools none --no-animation . >/dev/null
    echo "(exit 0)"
    
    write_meta() {
      local id="$1"
      mkdir -p "openspec/changes/${id}/specs/scheduling/dispatch"
      cat > "openspec/changes/${id}/.openspec.yaml" <<'EOF'
    schema: spec-driven
    created: 2026-10-06
    EOF
    }
    
    run_ok() {
      echo "+ $*"
      "$@"
      echo "(exit 0)"
    }
    
    echo "=== setup: archive the base capability ==="
    write_meta add-dispatch
    cat > openspec/changes/add-dispatch/specs/scheduling/dispatch/spec.md <<'EOF'
    ## Purpose
    
    Assigns one technician to a job that is ready to be dispatched.
    
    ## ADDED Requirements
    
    ### Requirement: Assign a technician
    The system SHALL assign a technician to a ready job.
    
    #### Scenario: Single technician
    - **WHEN** a job is ready for dispatch
    - **THEN** the system assigns one technician
    EOF
    run_ok "${OPENSPEC[@]}" validate add-dispatch --strict
    run_ok "${OPENSPEC[@]}" archive add-dispatch --yes
    
    echo "=== main spec after add-dispatch ==="
    cat openspec/specs/scheduling/dispatch/spec.md
    
    echo "=== change add-skill-filter: skill clause and Missing skill scenario ==="
    write_meta add-skill-filter
    cat > openspec/changes/add-skill-filter/specs/scheduling/dispatch/spec.md <<'EOF'
    ## MODIFIED Requirements
    
    ### Requirement: Assign a technician
    The system SHALL assign a technician to a ready job only when the technician has every required skill.
    
    #### Scenario: Single technician
    - **WHEN** a job is ready for dispatch
    - **THEN** the system assigns one technician
    
    #### Scenario: Missing skill
    - **WHEN** the technician lacks a required skill
    - **THEN** the system refuses the assignment
    EOF
    
    echo "=== change add-region-filter: region clause, copied from the pre-skill main spec ==="
    write_meta add-region-filter
    cat > openspec/changes/add-region-filter/specs/scheduling/dispatch/spec.md <<'EOF'
    ## MODIFIED Requirements
    
    ### Requirement: Assign a technician
    The system SHALL assign a technician to a ready job only within the job's service region.
    
    #### Scenario: Single technician
    - **WHEN** a job is ready for dispatch
    - **THEN** the system assigns one technician
    EOF
    
    echo "=== both changes validate before either is archived ==="
    run_ok "${OPENSPEC[@]}" validate add-skill-filter --strict
    run_ok "${OPENSPEC[@]}" validate add-region-filter --strict
    
    echo "=== archive add-skill-filter ==="
    run_ok "${OPENSPEC[@]}" archive add-skill-filter --yes
    echo "=== main spec after add-skill-filter ==="
    cat openspec/specs/scheduling/dispatch/spec.md
    
    echo "=== validate and archive add-region-filter against the updated main spec ==="
    echo "+ ${OPENSPEC[*]} validate add-region-filter --strict"
    set +e
    "${OPENSPEC[@]}" validate add-region-filter --strict
    validate_status=$?
    set -e
    echo "(exit ${validate_status})"
    echo "+ ${OPENSPEC[*]} archive add-region-filter --yes"
    set +e
    "${OPENSPEC[@]}" archive add-region-filter --yes
    archive_status=$?
    set -e
    echo "(exit ${archive_status})"
    if [[ "$archive_status" -eq 0 ]]; then
      echo "UNEXPECTED: archive succeeded while Missing skill was omitted" >&2
      exit 1
    fi
    
    echo "=== add the Missing skill scenario, keeping the stale requirement text ==="
    cat > openspec/changes/add-region-filter/specs/scheduling/dispatch/spec.md <<'EOF'
    ## MODIFIED Requirements
    
    ### Requirement: Assign a technician
    The system SHALL assign a technician to a ready job only within the job's service region.
    
    #### Scenario: Single technician
    - **WHEN** a job is ready for dispatch
    - **THEN** the system assigns one technician
    
    #### Scenario: Missing skill
    - **WHEN** the technician lacks a required skill
    - **THEN** the system refuses the assignment
    EOF
    
    echo "=== validate and archive the refreshed delta ==="
    run_ok "${OPENSPEC[@]}" validate add-region-filter --strict
    run_ok "${OPENSPEC[@]}" archive add-region-filter --yes
    
    echo "=== main spec after add-region-filter ==="
    cat openspec/specs/scheduling/dispatch/spec.md
    
    echo "=== proof ==="
    spec="openspec/specs/scheduling/dispatch/spec.md"
    if grep -q "only when the technician has every required skill" "$spec"; then
      echo "BUG NOT REPRODUCED: skill clause is still in the main spec"
      exit 1
    fi
    if ! grep -q "only within the job's service region" "$spec"; then
      echo "BUG NOT REPRODUCED: region clause is missing"
      exit 1
    fi
    if ! grep -q "#### Scenario: Missing skill" "$spec"; then
      echo "BUG NOT REPRODUCED: Missing skill scenario is missing"
      exit 1
    fi
    echo "BUG REPRODUCED: archive exited 0 and the main spec no longer contains \"only when the technician has every required skill\"."
    echo "The region clause and the Missing skill scenario are present."

    Actual output (1.14.1, trimmed)

    Setup archive add-dispatch --yes exits 0 and creates openspec/specs/scheduling/dispatch/spec.md with the original sentence and the Single technician scenario. Then:

    === both changes validate before either is archived ===
    + npx -y @fission-ai/openspec@1.14.1 --no-color validate add-skill-filter --strict
    Change 'add-skill-filter' is valid
    (exit 0)
    + npx -y @fission-ai/openspec@1.14.1 --no-color validate add-region-filter --strict
    Change 'add-region-filter' is valid
    (exit 0)
    === archive add-skill-filter ===
    + npx -y @fission-ai/openspec@1.14.1 --no-color archive add-skill-filter --yes
    Task status: No tasks
    
    Specs to update:
      scheduling/dispatch: update
    Applying changes to openspec/specs/scheduling/dispatch/spec.md:
      ~ 1 modified
    Totals: + 0, ~ 1, - 0, → 0
    Specs updated successfully.
    Change 'add-skill-filter' archived as '2026-10-06-add-skill-filter'.
    (exit 0)
    === main spec after add-skill-filter ===
    # scheduling/dispatch Specification
    
    ## Purpose
    Assigns one technician to a job that is ready to be dispatched.
    
    ## Requirements
    
    ### Requirement: Assign a technician
    The system SHALL assign a technician to a ready job only when the technician has every required skill.
    
    #### Scenario: Single technician
    - **WHEN** a job is ready for dispatch
    - **THEN** the system assigns one technician
    
    #### Scenario: Missing skill
    - **WHEN** the technician lacks a required skill
    - **THEN** the system refuses the assignment
    === validate and archive add-region-filter against the updated main spec ===
    + npx -y @fission-ai/openspec@1.14.1 --no-color validate add-region-filter --strict
    Change 'add-region-filter' has issues
    ✗ [ERROR] scheduling/dispatch/spec.md: MODIFIED "Assign a technician" omits scenario(s) the current spec still has: "Missing skill". The modified block has 1 scenario; the current spec has 2 scenarios. It adds none. Copy the omitted scenarios into the MODIFIED block (a MODIFIED requirement replaces the whole block, so archive refuses to drop them).
    Next steps:
      - Ensure change has deltas in specs/: use headers ## ADDED/MODIFIED/REMOVED/RENAMED Requirements
      - Each requirement MUST include at least one #### Scenario: block
      - Debug parsed deltas: openspec show add-region-filter --json --deltas-only
    (exit 1)
    + npx -y @fission-ai/openspec@1.14.1 --no-color archive add-region-filter --yes
    Task status: No tasks
    
    Specs to update:
      scheduling/dispatch: update
    scheduling/dispatch MODIFIED failed for header "### Requirement: Assign a technician" - current spec contains scenario(s) not present in the modified block: "Missing skill". The modified block has 1 scenario; the current spec has 2 scenarios. It adds none. Refresh the change spec before archiving to avoid dropping scenarios.
    Aborted. No files were changed.
    (exit 1)
    === add the Missing skill scenario, keeping the stale requirement text ===
    === validate and archive the refreshed delta ===
    + npx -y @fission-ai/openspec@1.14.1 --no-color validate add-region-filter --strict
    Change 'add-region-filter' is valid
    (exit 0)
    + npx -y @fission-ai/openspec@1.14.1 --no-color archive add-region-filter --yes
    Task status: No tasks
    
    Specs to update:
      scheduling/dispatch: update
    Applying changes to openspec/specs/scheduling/dispatch/spec.md:
      ~ 1 modified
    Totals: + 0, ~ 1, - 0, → 0
    Specs updated successfully.
    Change 'add-region-filter' archived as '2026-10-06-add-region-filter'.
    (exit 0)
    === main spec after add-region-filter ===
    # scheduling/dispatch Specification
    
    ## Purpose
    Assigns one technician to a job that is ready to be dispatched.
    
    ## Requirements
    
    ### Requirement: Assign a technician
    The system SHALL assign a technician to a ready job only within the job's service region.
    
    #### Scenario: Single technician
    - **WHEN** a job is ready for dispatch
    - **THEN** the system assigns one technician
    
    #### Scenario: Missing skill
    - **WHEN** the technician lacks a required skill
    - **THEN** the system refuses the assignment
    === proof ===
    BUG REPRODUCED: archive exited 0 and the main spec no longer contains "only when the technician has every required skill".
    The region clause and the Missing skill scenario are present.
    

    only when the technician has every required skill is not in the main spec after the second archive. Missing skill is still there.

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

    design-reviewNeeds product/design decision

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions