Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-10
119 changes: 119 additions & 0 deletions openspec/changes/add-scenario-rename-declaration/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Design

## The decision this change makes

#1697 asked two questions before anyone wrote code, and both are answered here.

**Q1: is an explicit scenario-level operator the direction?** Yes, and this
change proposes the spelling. The alternatives are recorded below.

**Q2: should it also cover `REMOVED Scenario`?** Not here. See "Deliberately out
of scope".

## Why the declaration is a bullet inside the block, not a `####` header

The follow-up comment on #1697 suggested `#### RENAMED Scenario` with `FROM:`/
`TO:` bullets beneath it. **That spelling cannot work**, and the reason is worth
stating because it is the kind of thing that looks fine until the guard's
arithmetic is wrong:

`parseScenarioBlocks` counts EVERY non-fenced level-4 header as a scenario, on
purpose — the spec path's `countScenarios` does, so the delta path must, or a
`#### Edge case` dropped by a `MODIFIED` block slips past the loss check
(#1521, and `SCENARIO_HEADER`'s own comment warns against breaking that
parity). A `#### RENAMED Scenario` heading would therefore enter the comparison
as a scenario in its own right: the block gets credited with a scenario nobody
wrote, and `findMissingCurrentScenarios` starts answering a question about the
wrong set. A bullet cannot collide with it.

So the declaration is `#1697`'s own "Suggested fix" option 1, unchanged:

```markdown
- RENAMED SCENARIO FROM: `#### Scenario: Old title`
- RENAMED SCENARIO TO: `#### Scenario: New title`
```

## Why inside the requirement block, rather than a top-level section

A `## RENAMED Scenarios` section parallel to `## RENAMED Requirements` was the
other option #1793 offered. Two things decide against it:

1. **A scenario title is only unique within its requirement.** A top-level
section must name the requirement for context, making every declaration a
three-line form. Inside the block, the context IS the block.
2. **It would put the declaration on the wrong side of the parity seam.**
`findMissingCurrentScenarios(current, incoming)` is the ONE function archive
and validate share, exactly so the two cannot disagree about what counts as
a dropped scenario (#1477). A declaration carried in the block is readable
from `incoming.raw`, so both commands get it from the function they already
both call, with no new argument and no plumbing at either call site. A
top-level section would have to be threaded from `DeltaPlan` through both
paths, and any caller that missed the new argument would silently lose the
declaration — reopening precisely the validate/archive divergence #1477
closed.

## Why a declaration is a claim, not a suppression

The stated successor must be a scenario in the same block. Without that check
the declaration would be a blanket `ignore this title`, and #1697's replay data
is unambiguous that the guard is catching real losses (4 caught by hand at
archive time, 2 shipped and repaired later). Requiring the successor means a
declaration can only ever say "this old title is now that new one", and the new
one has to be there to say it.

Consequences, all deliberate:

- **No credit without a successor.** A declaration naming an absent successor
grants nothing, so the omission is still reported as a loss. The author gets
both findings: the loss, and the reason the declaration did not answer it.
- **One instance per declaration.** Credit is multiplicity-aware like every
other name in the comparison, so two instances of a title and one declaration
still leaves one unaccounted for — a duplicate cannot hide a real loss behind
a single declaration (#1246 / #1391).
- **Read from the incoming block only.** A declaration is never read out of the
current spec. One that survived into canon would otherwise become a standing
exemption for that title, which is the loophole the guard exists to refuse.
(It cannot survive into canon anyway — see the strip below — but the parser
does not rely on that.)
- **Merges are allowed.** Several declarations MAY name one successor: two
scenarios superseded by a single narrower one. That is the shape both
reporters described (#1793's house marker is literally spelled "Merged
into"), so it is permitted rather than special-cased.
- **Unpaired halves are reported.** A `FROM:` with no `TO:`, or a `TO:` with no
`FROM:`, is an error. #1806 found the requirement-level parser silently
dropping unpaired halves, so a requested rename never happened while archive
reported success; the same shape is refused here from the start rather than
discovered later.

## Why the declaration does not land in the main spec

A main spec is what deltas merge INTO, and it never carries delta operation
markers — the sync guidance states this. The declaration is change-scoped
bookkeeping: leaving it behind would accrete one line per rename in the spec
forever and hand the next author a marker to copy forward into their own
`MODIFIED` block.

It is stripped before the "already in sync" comparison as well as before the
write, so re-archiving a change whose only edit was a declared rename is a
no-op rather than a whitespace rewrite.

## Deliberately out of scope

- **`REMOVED Scenario`** (#1697 Q2). A deliberate deletion is the other half of
the "declare the omission" problem — 7 of the 26 hand-classified findings —
but it is a strictly larger semantic: a rename requires a successor and
authorizes no loss, while a removal authorizes loss and needs its own
justification and probably its own reason text. Folding it in here would ship
the loss-authorizing operator on the rename's evidence.
- **Scenario ids.** One corpus prefixes headings with `[cap-NNN]` and renumbers
them in bulk (14 in one pass), which reads as a drop for the same reason. Each
renumber is expressible as a declaration, but a first-class scenario identity
would be a much larger format change (`ScenarioSchema` is `{ rawText }` and
has no name field at all) and is not proposed here.
- **The stale-base class** (#1112 and #1697's 4 findings). Still blocked, and
correctly: the remedy is reconciling against the current base.
- **Reporting the counts and added names when the guard fires** — #1809, open
and independent. It prints facts and draws no conclusion; this change adds the
declaration that lets an author state one. Neither needs the other. The two
touch `findMissingCurrentScenarios`; this change leaves its signature and
return type alone to keep the overlap textual rather than semantic.
125 changes: 125 additions & 0 deletions openspec/changes/add-scenario-rename-declaration/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
## Why

A `MODIFIED` requirement replaces its whole block, so the loss guard refuses one
that omits a scenario the main spec still has. That guard is right, and four
merged PRs have hardened it (#1246/#1391 multiplicity, #1475 fences, #1521 every
level-4 header). But it compares scenario TITLES, so a deliberate **rename** is
indistinguishable from an accidental **drop**, and the only edit that satisfies
it is restoring the old title — reverting the change where the rename was the
point.

Two reports, two corpora:

- **#1697** — renaming a scenario heading while keeping the requirement is
impossible. Six workarounds tried; the ones that work either keep a heading
that no longer describes the scenario, or disable every other check with
`archive --no-validate`. Where a project names its tests after scenario
headings, the heading is the traceability key, not decoration.
- **#1793** — the narrowed successor CONTRADICTS its predecessor, so "just keep
both scenarios" puts two conflicting statements in one requirement block. That
reporter runs the CLI digest-pinned and reconciles `--json` findings against a
written per-finding disposition list, which works and is strictly worse than
the check being able to read the declaration: the exception lives in their
wrapper, invisible to anyone reading the delta, re-derived at every upgrade.

A replay over 75 archived changes in a third corpus (#1697) measured **29 of 75
changes blocked, 99 findings, every detection correct** — and of the 26 findings
hand-classified, **16 (62%) were intended omissions**. The guard is earning its
keep: 4 were real losses a human caught at archive time and 2 shipped and needed
repair by a later change. So the ask is not weaker detection. It is a way to
STATE INTENT, because intent is not recoverable from structure — one block in
that corpus produced 6 findings against one requirement, 2 renames and 4
accidents, identical in scenario count, heading and id presence.

There is already precedent for an author-declared exclusion in this very check:
a `MODIFIED` whose requirement is renamed away by `## RENAMED Requirements` in
the same delta is skipped (`renamedAway`). What is missing is the same
affordance one level down, at the scenario.

## What Changes

- **A `MODIFIED` requirement block MAY declare a scenario rename**, written one
level down from `## RENAMED Requirements` and inside the block it applies to:

```markdown
### Requirement: Full car park refuses entry
The system SHALL refuse entry when no bay is free, unless the driver holds a permit.

- RENAMED SCENARIO FROM: `#### Scenario: Car arrives at a full car park`
- RENAMED SCENARIO TO: `#### Scenario: Car without a permit arrives at a full car park`

#### Scenario: Car without a permit arrives at a full car park
- **WHEN** a car arrives, no bay is free, and the driver holds no permit
- **THEN** entry is refused
```

- **A declaration accounts for the old title; it does not suppress the check.**
The stated successor must really be a scenario in the same block. Where it is
not, no credit is granted, the omission is still reported as the loss it is,
and the unbacked declaration is reported as well.
- **Every declaration is paired or reported.** An unpaired `FROM:` or `TO:` is
an error rather than a silent no-op — the class #1806 found in the
requirement-level parser, where a dropped half meant a requested rename never
happened while archive reported success.
- **`validate` and `archive` agree**, sharing both the comparison and the
sentence, so a declaration accepted at authoring time cannot be refused at
archive time (the parity `findMissingCurrentScenarios` exists for, #1477).
- **The declaration does not land in the main spec.** It is delta bookkeeping,
and a main spec never carries delta operation markers.

### What this deliberately does NOT change

- **Detection is untouched.** An UNDECLARED omission is reported exactly as
today, at the same level, with the same message and exit code. Every existing
test passes unmodified.
- **The stale-base class stays blocked.** A delta authored before a sibling
change added a scenario still errors, which is correct: the remedy there is to
reconcile against the current base, not to declare an intent the author never
had. #1697's replay separates that class (4 of 26) precisely because its
remedy differs.
- **No `REMOVED Scenario` operator.** Deliberately dropping a scenario is the
other half of "declare the omission" (#1697's open question 2), and it is a
strictly larger semantic: a rename REQUIRES a successor and authorizes no
loss, whereas a removal authorizes loss and needs its own justification. It
should be decided on its own evidence, not folded in here.
- **No new flag, no config, no severity knob.** #1793 offers per-finding
suppression and a severity knob as fallbacks and calls them weaker; both move
intent out of the delta, which is the thing that matters.
- **No inference.** Nothing looks at scenario bodies to guess whether two
titles are "the same" behaviour. That is what the hardening PRs closed.

## Capabilities

### Modified Capabilities

- `openspec-conventions`: the delta format gains a scenario-level rename
declaration carried inside a `MODIFIED` requirement block, and states that
the declaration is stripped when the block lands in the main spec.
- `cli-validate`: the dropped-scenario check accounts for a declared rename
whose successor is present, and reports a declaration that is unpaired or
whose successor is absent.

## Impact

- **Affected specs:** `openspec-conventions` (1 added requirement),
`cli-validate` (1 modified, 1 added requirement).
- **Affected code:**
- `src/core/parsers/requirement-blocks.ts` — the declaration parser, the
integrity check, the shared message, the strip, and the credit seeded into
`findMissingCurrentScenarios`. Its signature and return type are unchanged.
- `src/core/validation/validator.ts` — report declaration problems.
- `src/core/specs-apply.ts` — refuse them, and strip declarations from the
block that lands in the spec.
- **Risk:** low. The declaration is opt-in and lexically new, so no existing
delta can accidentally contain one; a corpus that writes none behaves
identically. The one behaviour change for existing deltas is that a
`MODIFIED` block containing a line that begins `RENAMED SCENARIO FROM:` or
`TO:` is now read as a declaration rather than prose.

## Issues addressed

- [#1697](https://github.com/Fission-AI/OpenSpec/issues/1697) — cannot rename a
scenario; `MODIFIED` reads a rename as a dropped scenario and blocks archive.
- [#1793](https://github.com/Fission-AI/OpenSpec/issues/1793) — the
scenario-currency check has no way to declare a deliberate scenario rename, so
a narrowing reads as an omission.
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## MODIFIED Requirements

### Requirement: Change validation SHALL report scenarios a MODIFIED block would drop

The `validate` command SHALL compare every `MODIFIED` requirement in a change against the main specs and report, as an error naming the delta file, each scenario the main spec still has that the `MODIFIED` block omits and does not declare renamed. A `MODIFIED` requirement replaces the whole requirement block, so archive refuses to apply one that drops a scenario; this is the same check, run without writing anything.

The comparison SHALL match archive's operation order, comparing a `MODIFIED` that names the new header of a rename against the renamed requirement's scenarios.

A scenario the block declares renamed — `RENAMED SCENARIO FROM:`/`TO:`, with the scenario named by `TO:` present in the same block — SHALL be accounted for and not reported. The declaration SHALL be read from the `MODIFIED` block only, never from the main spec, and SHALL account for exactly one instance of the old title. Where the named successor is absent no old title SHALL be accounted for, so the omission is still reported.

The check SHALL be silent when the main spec file or the requirement header is absent, because a `MODIFIED` written against a base that has not landed yet is a separate condition that archive gates. A main spec that exists but cannot be read SHALL be reported instead, since archive fails on it too.

Validation run inside `openspec archive` SHALL NOT report these issues, because archive enforces the same check when it applies the deltas.

#### Scenario: MODIFIED omits an existing scenario

- **GIVEN** the main spec's requirement has scenarios "A" and "B"
- **WHEN** a change MODIFIES that requirement with only scenario "A" and `openspec validate <change>` runs
- **THEN** report an error naming the delta file and scenario "B"
- **AND** exit with code 1

#### Scenario: MODIFIED names the new header of a rename

- **GIVEN** the main spec has requirement "A" with scenarios "S1" and "S2"
- **WHEN** a change renames "A" to "B" and MODIFIES "B" with only scenario "S1"
- **THEN** report an error naming scenario "S2"

#### Scenario: MODIFIED header is not in the main spec

- **GIVEN** a change MODIFIES a requirement header the main spec does not contain
- **WHEN** `openspec validate <change>` runs
- **THEN** do not report a dropped-scenario error for that requirement

#### Scenario: MODIFIED declares the omitted scenario renamed

- **GIVEN** the main spec's requirement has scenario "A"
- **WHEN** a change MODIFIES that requirement with scenario "B", declaring `RENAMED SCENARIO FROM: A` and `RENAMED SCENARIO TO: B`
- **THEN** do not report "A" as an omission
- **AND** `openspec archive` SHALL apply the block

## ADDED Requirements

### Requirement: Change validation SHALL report a scenario rename declaration it cannot back up

The `validate` command SHALL report, as an error naming the delta file and the requirement, each `RENAMED SCENARIO` declaration in a `MODIFIED` block that does not describe a rename the block carries out: a `FROM:` with no `TO:` after it, a `TO:` with no `FROM:` before it, or a `TO:` naming a scenario title the block does not contain. The error SHALL name the declared title and the line within the requirement block.

`openspec archive` SHALL refuse the same block with the same sentence rather than guess at what the declaration meant, so a declaration accepted by `validate` cannot be refused by `archive`.

#### Scenario: Declared successor is not in the block

- **GIVEN** a `MODIFIED` block declaring `RENAMED SCENARIO TO:` a title the block does not contain
- **WHEN** `openspec validate <change>` runs
- **THEN** report an error naming that title and saying the block has no scenario with it
- **AND** `openspec archive` SHALL refuse the block with the same sentence

#### Scenario: Unpaired declaration half

- **GIVEN** a `MODIFIED` block with a `RENAMED SCENARIO FROM:` line and no `RENAMED SCENARIO TO:` after it
- **WHEN** `openspec validate <change>` runs
- **THEN** report an error naming the declared title and the line within the requirement block

#### Scenario: A declaration inside a fenced example

- **GIVEN** a `MODIFIED` block whose body documents the declaration syntax inside a fenced code block
- **WHEN** `openspec validate <change>` runs
- **THEN** do not treat it as a declaration, and do not report it
Loading