Repository navigation
Pre-archive drift and overlap checking for parallel changes: working tool, offer to contribute #1387
Description
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 maintarget-missing: the name matches nothing, usually a typo, so archive can't apply the entryname-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.jsonbase 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:
- Would you take some or all of this upstream (as subcommands or a validation pass), or would you rather it stayed a companion tool?
- 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.
- Is the
meta.jsonsnapshot 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
feat(validate): report requirements two active changes both claim
feat(validate): report requirements two active changes both claim
ryandemelo commentedon Aug 22, 2026
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 overlaphalf — requirements claimed by more than one active change, reported byvalidate, 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, andfindMissingCurrentScenarios(: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.jsonsnapshots 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 themeta.jsonfile?" 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
MODIFIEDblock can't rename a scenario, becausefindMissingCurrentScenarioscompares 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 aMODIFIEDblock, 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-missingfinding also maps onto a live bug.validateonly compares against the main spec whenmainSpecsDiris set and the delta has MODIFIED blocks (src/core/validation/validator.ts:312), and thenif (!current) continue(:607) skips a MODIFIED whose target is missing entirely. Meanwhilespecs-apply.tsthrows 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).validatecovers 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_HEADERis/^####\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 insrc/core/store/git.ts. Rename-awarevalidateis 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.
ryandemelo commentedon Aug 22, 2026
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.
Marzx13 commentedon Aug 22, 2026
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 -> Newfollowed by MODIFIEDNewsucceeds, but a missing-source rename toNewfollowed by MODIFIEDNewreports 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.
feat(validate): report requirements two active changes both claim
feat(validate): report requirements two active changes both claim
fidelix commentedon Oct 6, 2026
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,
archiveexits 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 namesMissing skilland 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:
validateorarchiveshould detect that theMODIFIEDblock 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
Actual output (1.14.1, trimmed)
Setup
archive add-dispatch --yesexits 0 and createsopenspec/specs/scheduling/dispatch/spec.mdwith the original sentence and theSingle technicianscenario. Then:only when the technician has every required skillis not in the main spec after the second archive.Missing skillis still there.feat(validate): report requirements two active changes both claim