From 1e7d435e37af87d791e948a954aee3312264c377 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:26:32 -0400 Subject: [PATCH] docs(openspec): propose a MODIFIED-block scenario rename declaration Per CONTRIBUTING's format-change process: a proposal only, ahead of implementation. A MODIFIED requirement block's scenario-currency check (validate and archive) compares scenario titles, so a deliberate rename reads the same as an accidental drop and the only edit that satisfies the check is reverting the rename. This proposes a declaration the block can carry to say a scenario was renamed rather than dropped, answering #1697's design-review question with a concrete spelling and design rationale (see design.md for the alternatives considered and why each was rejected). An implementation is written and tested; it is on a separate branch, linked from the PR, pending this proposal's review. Refs #1793, #1697 --- .../.openspec.yaml | 2 + .../add-scenario-rename-declaration/design.md | 119 +++++++++++++++++ .../proposal.md | 125 ++++++++++++++++++ .../specs/cli-validate/spec.md | 66 +++++++++ .../specs/openspec-conventions/spec.md | 53 ++++++++ .../add-scenario-rename-declaration/tasks.md | 55 ++++++++ 6 files changed, 420 insertions(+) create mode 100644 openspec/changes/add-scenario-rename-declaration/.openspec.yaml create mode 100644 openspec/changes/add-scenario-rename-declaration/design.md create mode 100644 openspec/changes/add-scenario-rename-declaration/proposal.md create mode 100644 openspec/changes/add-scenario-rename-declaration/specs/cli-validate/spec.md create mode 100644 openspec/changes/add-scenario-rename-declaration/specs/openspec-conventions/spec.md create mode 100644 openspec/changes/add-scenario-rename-declaration/tasks.md diff --git a/openspec/changes/add-scenario-rename-declaration/.openspec.yaml b/openspec/changes/add-scenario-rename-declaration/.openspec.yaml new file mode 100644 index 0000000000..e8cda9e50e --- /dev/null +++ b/openspec/changes/add-scenario-rename-declaration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-10 diff --git a/openspec/changes/add-scenario-rename-declaration/design.md b/openspec/changes/add-scenario-rename-declaration/design.md new file mode 100644 index 0000000000..7f0ff8ee70 --- /dev/null +++ b/openspec/changes/add-scenario-rename-declaration/design.md @@ -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. diff --git a/openspec/changes/add-scenario-rename-declaration/proposal.md b/openspec/changes/add-scenario-rename-declaration/proposal.md new file mode 100644 index 0000000000..35aeb65217 --- /dev/null +++ b/openspec/changes/add-scenario-rename-declaration/proposal.md @@ -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. diff --git a/openspec/changes/add-scenario-rename-declaration/specs/cli-validate/spec.md b/openspec/changes/add-scenario-rename-declaration/specs/cli-validate/spec.md new file mode 100644 index 0000000000..e6ecd8da5b --- /dev/null +++ b/openspec/changes/add-scenario-rename-declaration/specs/cli-validate/spec.md @@ -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 ` 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 ` 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 ` 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 ` 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 ` runs +- **THEN** do not treat it as a declaration, and do not report it diff --git a/openspec/changes/add-scenario-rename-declaration/specs/openspec-conventions/spec.md b/openspec/changes/add-scenario-rename-declaration/specs/openspec-conventions/spec.md new file mode 100644 index 0000000000..4538ac43ab --- /dev/null +++ b/openspec/changes/add-scenario-rename-declaration/specs/openspec-conventions/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Scenario Rename Declaration + +A `MODIFIED` requirement block SHALL be able to declare that a scenario the main spec still carries was renamed rather than dropped, so that a deliberate rename is distinguishable from an accidental omission without weakening the omission check. + +The declaration is written inside the `MODIFIED` requirement block it applies to, one level down from `## RENAMED Requirements`: + +```markdown +## MODIFIED Requirements + +### 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 +``` + +The bullet is optional and every CommonMark bullet marker is accepted, matching `## RENAMED Requirements`. The declared name MAY be written as a full `#### Scenario: Title` header, as `Scenario: Title`, or as the bare `Title`, with or without surrounding backticks; all three name the same scenario. A declaration inside a fenced code block declares nothing. + +#### Scenario: Declaring a scenario rename + +- **WHEN** a `MODIFIED` requirement retitles a scenario the main spec still has +- **THEN** the block MAY carry a `RENAMED SCENARIO FROM:` line followed by a `RENAMED SCENARIO TO:` line +- **AND** the old title SHALL be accounted for rather than reported as an omission +- **AND** the scenario named by `TO:` SHALL be present in the same block + +#### Scenario: A declaration whose successor is absent + +- **WHEN** a `MODIFIED` block declares a rename to a scenario title the block does not contain +- **THEN** the declaration SHALL be reported as an error +- **AND** the old title SHALL still be reported as an omission, because nothing accounted for it + +#### Scenario: An unpaired declaration + +- **WHEN** a `RENAMED SCENARIO FROM:` line has no `RENAMED SCENARIO TO:` after it, or a `TO:` line has no `FROM:` before it +- **THEN** it SHALL be reported as an error rather than ignored + +#### Scenario: Several scenarios merged into one + +- **WHEN** two scenarios are superseded by a single narrower one +- **THEN** the block MAY carry one declaration per old title, each naming that same successor +- **AND** each declaration SHALL account for exactly one instance of its old title + +#### Scenario: Declarations do not reach the main spec + +- **WHEN** a `MODIFIED` block carrying declarations is applied to the main spec +- **THEN** the declaration lines SHALL NOT appear in the main spec, which never carries delta operation markers +- **AND** applying the same block again SHALL be recognized as already in sync diff --git a/openspec/changes/add-scenario-rename-declaration/tasks.md b/openspec/changes/add-scenario-rename-declaration/tasks.md new file mode 100644 index 0000000000..c74f2921af --- /dev/null +++ b/openspec/changes/add-scenario-rename-declaration/tasks.md @@ -0,0 +1,55 @@ +# Tasks + +## 1. Format + +- [x] 1.1 Add the scenario-rename declaration to `openspec-conventions`: the + `FROM:`/`TO:` spelling, that it lives inside the `MODIFIED` requirement + block, that the successor must be present, and that it is stripped from + the block that lands in the main spec. +- [x] 1.2 Record in `cli-validate` that the dropped-scenario check accounts for + a declared rename, and that an unpaired or unbacked declaration is an + error. Reproduce the requirement's existing scenarios in the MODIFIED + block (none of them is renamed). + +## 2. Parser + +- [x] 2.1 `parseScenarioRenames(requirementRaw)` in + `src/core/parsers/requirement-blocks.ts`: fence-masked, bullet optional, + every CommonMark marker, backticks optional, names normalized through the + same `scenarioNameAt` every `#### ` header runs through. +- [x] 2.2 Record every half that never paired instead of dropping it (#1806's + class one level up). +- [x] 2.3 `findScenarioRenameProblems(incoming)` — unpaired halves plus a + declared successor the block does not contain. A function of the incoming + block alone, so it needs no current spec. +- [x] 2.4 `describeScenarioRenameProblem(problem)` — one shared sentence, so + validate and archive cannot describe the same declaration differently. +- [x] 2.5 Seed `findMissingCurrentScenarios` with one credit per declaration + whose successor is present. Signature and return type unchanged. +- [x] 2.6 `stripScenarioRenameDeclarations(requirementRaw)` — fence-aware, and + it removes the blank line the declaration left behind so a block with no + declaration comes out byte-identical. + +## 3. Commands + +- [x] 3.1 `validate`: report each declaration problem as an ERROR naming the + delta file, after the `renamedAway` skip so a block already reported for + naming an old requirement header is not complained about twice. +- [x] 3.2 `archive`: refuse a block whose declaration it cannot back up, before + the loss check, with the same sentence validate prints. +- [x] 3.3 `archive`: write the block with its declarations stripped, and strip + before the "already in sync" comparison too. + +## 4. Tests + +- [x] 4.1 Parser unit tests: pairing, the three accepted name forms, every + bullet marker, fence masking, unpaired halves, absent successor, + multiplicity, merge, a declaration in the CURRENT spec ignored, and the + strip (including byte-identity for a block with none). +- [x] 4.2 Parity tests through both commands on both reported reproductions: + accepted and applied, the declaration absent from the written spec, + already-in-sync on a second archive, and refused in both commands for an + absent successor and an unpaired `FROM:`. +- [x] 4.3 Regression: an UNDECLARED omission still reported, and only the + undeclared one when a declared rename sits beside it. +- [x] 4.4 Full suite green with no existing test modified.