Files
OpenSpec/docs/writing-specs.md
T
Clay Good 18688c8b27 fix(archive): never dead-end a capability retirement (#1699)
* fix(archive): never dead-end a capability retirement

A change whose delta removes the last requirement a capability has
rebuilds the main spec empty, which can never validate. Archive already
knows retiring is the fix and names the `retire_capabilities: true`
marker that authorises deleting the spec - but only when the marker is
the single thing missing.

If the spec also holds a line the merge cannot account for (a `## Notes`
section, a comment under a requirement - both ordinary), that hint was
suppressed, and the hint that names such lines only spoke to authors who
had already set the marker. Neither fired, so the archive aborted on
"Spec must have at least one requirement" with no guidance at all: the
exact dead end the marker exists to close.

Archive now names the blocking content in that case. It deliberately
does not name the marker there - adding it would not have let this run
through, and the marker is only ever named when it really is the one
thing missing. Once the content is resolved, the rerun names the marker.

Closes #1696

* fix(archive): harden the blocked-retirement abort

Three follow-ups to the same message.

The blocking lines are authored spec content printed verbatim to a
terminal, so they now get the treatment `describeChangeName` already
gives a change directory name: control characters replaced, since a raw
CR could forge a line of its own and an ESC could redraw the screen.
Each line is bounded too - one very long line would push the way out of
the abort off the reader's screen - and the cut counts code points so it
can never leave half a surrogate pair. Both the declared and undeclared
branches share the helper, so the marker-declared abort that shipped
with #1484 is hardened with it.

The wording no longer claims retiring is "the way through". It is not,
in the one case this fires on that has a live requirement hiding in a
second `## Requirements` section: merging the sections fixes that spec
without deleting anything.

`openspec/specs/cli-archive/spec.md` records the behavior change - the
blocking lines are named whether or not the marker was declared, and the
marker is still named only when adding it would let the archive through.

* refactor(archive): drop a helper the revised wording made single-use

The marker sentence is said in one place again, so it goes back inline
rather than through a function that now has one caller. Also corrects
the comment above `emptiedByThisRun`: retiring is not the only fix in
every case it covers, which is exactly why the message stopped saying so.

* docs(openspec): record the change as a delta, not a direct spec edit

Both conventions exist in this repo's history, but the two most recent
behavior fixes (#1609, #1616) carry an `openspec/changes/` delta rather
than editing the main spec in place, which is also the workflow this
project asks of everyone else.

The delta reproduces the whole Capability Retirement requirement, so
archiving it drops no scenario. Verified by archiving into a scratch
copy of `openspec/`: the merged main spec differs from today's by
exactly the three added bullets.

* fix(archive): report an unhonorable marker alongside the blocking content

An author who set `retire_capabilities: yes-please` believes they have
authorised the deletion. Clearing the blocking content first, only to
then learn the marker was never read, is two aborts for one mistake.

The abort still never invites the marker to be added while content
blocks the retirement - it only reports the one already there. The spec
delta records that distinction, which the old bullet ("say nothing about
the marker") did not draw.

* style(archive): use one sentence for an unhonorable marker in both aborts

* fix(metadata): strip control characters from an unhonorable marker reason

Every reason a boolean change-metadata marker gives quotes something the
author wrote - a schema name, a parser message carrying one, a
filesystem error carrying a path - and two commands print it straight to
a terminal. A schema name carrying a raw ESC, with the marker set, put
that ESC on screen through `openspec archive`; `openspec validate`
prints the same reason.

Fixed at the source in `readBooleanMarker` rather than at either call
site, so no consumer has to remember. The reason still quotes the name
recognisably; only control characters are replaced.

Reported by CodeRabbit on #1699. Pre-existing on main, and this PR would
have added a second place it reaches the terminal.

* test(archive): fix a comment left behind by the reworded abort
2026-08-19 20:19:34 +00:00

8.0 KiB

Writing Good Specs

You rarely write a spec from a blank page. You describe a change in plain language, /opsx:propose drafts the requirements and scenarios, and then you make them good. This page is about that last part — what "good" looks like, and how to steer the AI toward it.

It's the companion to Reviewing a Change: reviewing is catching the weak spots in a draft, writing is knowing what a strong one is made of.

A spec is behavior, not code

A spec says what your system does, in terms anyone could check — not how it's built. It's made of requirements (statements of behavior) and scenarios (concrete examples that prove them).

### Requirement: Session Timeout
The system SHALL expire a session after 30 minutes of inactivity.

#### Scenario: Idle timeout
- GIVEN an authenticated session
- WHEN 30 minutes pass with no activity
- THEN the session is invalidated and the user must re-authenticate

Keep the how — the queue, the library, the table schema — in design.md or the code. When behavior and implementation get mixed into one requirement, the requirement stops being testable and starts going stale the moment the code changes.

What makes a good requirement

A good requirement is one behavior, stated so plainly you could hand it to someone else to test.

  • One statement, one SHALL/MUST. If a requirement has three "and also" clauses, it's really three requirements. Split them.

  • Observable. Someone outside the code should be able to tell whether it holds. "The system SHALL show an error banner when the upload exceeds 10 MB" is observable. "The system SHALL handle large uploads gracefully" is not.

  • The right strength. OpenSpec uses the RFC 2119 keywords, and they mean different things:

    Keyword Meaning
    MUST / SHALL A hard requirement. Non-negotiable.
    SHOULD A strong recommendation, with room for a justified exception.
    MAY Genuinely optional.

    Reach for MUST/SHALL by default. Use SHOULD only when you truly mean "unless there's a good reason not to."

The test for a requirement: could a tester who's never seen the code tell whether it passed? If not, it needs sharpening.

What makes a good scenario

Scenarios are where a requirement earns its keep. Each one is a concrete GIVEN / WHEN / THEN that could become an automated test.

  • It exercises its requirement. A scenario that just restates the requirement in other words tests nothing. Make it a specific situation with a specific outcome.
  • Cover the cases that matter, not just the happy path. The valid login is easy. The empty input, the expired token, the second click, the thing that goes wrong — those are where bugs live, and where a scenario is worth the most.
  • Name the case in the title. "Scenario: Rejects an expired token" tells a reviewer what's covered at a glance; "Scenario: Test 2" doesn't.

A useful habit: before approving, ask what's the one case I'd be upset to see broken? — and make sure a scenario names it.

Pick the right kind of delta

A change describes its edits to the specs with three section types. Using the right one keeps your archived specs honest:

  • ## ADDED Requirements — brand-new behavior that didn't exist before.
  • ## MODIFIED Requirements — behavior that already existed and is changing. Include the full new version; a short note on what changed helps a reviewer.
  • ## REMOVED Requirements — behavior going away, with a line on why.

On archive, ADDED gets appended to the main spec, MODIFIED replaces the old version, and REMOVED is dropped from it. Remove the last requirement a capability has and you retire it: rather than leave a spec with nothing in it, archive deletes openspec/specs/<capability>/spec.md. Because that is the one archive step that removes a file, it has to be asked for — add retire_capabilities: true to the change's .openspec.yaml, alongside the schema: that file already needs. Without it the archive aborts and tells you so. Retirement deletes the whole file, so it is also refused while the spec holds anything outside its title, ## Purpose, and its requirement blocks — a ## Notes section, a comment under a requirement. The abort names those lines; move them into ## Purpose or a requirement, or delete the spec by hand. For a spec in the caller's checkout, the archive output also names the git checkout that restores a committed file; selected stores receive checkout-scoped recovery guidance instead. If you mark a real change as ADDED, you end up with two competing requirements; if you describe new behavior as MODIFIED, there's nothing to replace. When in doubt, open the current spec and see whether the requirement is already there.

One more section is worth knowing about. When your delta creates a capability that doesn't exist yet, open it with ## Purpose — a sentence or two on what the capability is for. Archive uses it as the Purpose of the main spec it creates; skip it and you get a TBD placeholder to fill in by hand. An existing spec already has a Purpose, so a delta's is ignored there — edit openspec/specs/<capability-path>/spec.md directly to change one. Here, <capability-path> is the directory relative to specs/, such as user-auth in a flat project or identity/user-auth in a project organized by domain.

Right-size the change

The single most common authoring mistake isn't a badly worded requirement — it's a change that's trying to be three changes.

A good change has one intent you can say in a sentence. "Add a dark-mode toggle." "Rate-limit the login endpoint." "Migrate sessions off cookies." If describing the change needs a lot of "and also," that's the signal to split it.

Signs a change is too big:

  • The proposal's scope reads like a list of unrelated features.
  • Reviewing it would take an afternoon, so nobody will.
  • Two people couldn't work on it without colliding.
  • Half the tasks could ship on their own.

Smaller changes are easier to review, easier to build in one focused session, and easier to reason about six months later when the archive is all that's left. You can always run several changes in parallel — see Editing & iterating and Workflows.

The opposite also happens: a one-line typo fix doesn't need three requirements and a design doc. Match the ceremony to the stakes.

How to steer the AI toward a good draft

Because /opsx:propose does the first draft, the quality of what you get back tracks the quality of what you give it. You don't have to write requirements by hand — you have to aim the AI well:

  • State the intent and the boundary. "Add a dark-mode toggle that follows the OS setting on first load — don't touch the existing theme API." The out-of-scope half matters as much as the in-scope half.
  • Name the cases you care about. "Make sure there's a scenario for a user who already picked a theme manually." The AI covers what you point at.
  • Then edit. It's plain Markdown. Tighten a vague SHALL, delete a scenario that tests nothing, add the case it missed — or ask the AI to: "the timeout requirement is vague, pin it to 30 minutes."

Draft, sharpen, repeat. A few rounds of that produces a spec you'd trust, which is the whole point.

A quick checklist

  • Each requirement is one observable behavior with a SHALL/MUST.
  • No implementation details are baked into the requirements.
  • Every requirement has at least one scenario that actually exercises it.
  • The important edge and error cases have scenarios, not just the happy path.
  • Deltas use ADDED / MODIFIED / REMOVED correctly against the current spec.
  • The whole change has one intent you can state in a sentence.

Where to go next