Skip to main content
Version: 0.1.124

Writing Gherkin

Gherkin uses a set of special keywords to give structure and meaning to executable specifications. Each keyword is translated to many spoken languages; in this reference we'll use English.

Most lines in a Gherkin document start with one of the keywords.

Comments are only permitted at the start of a new line, anywhere in the feature file. They begin with zero or more spaces, followed by a hash sign (#) and some text. Block comments are currently not supported by Gherkin.

Either spaces or tabs may be used for indentation. The recommended indentation level is two spaces. Here is an example:

Feature: Guess the word

# The first example has two steps
Scenario: Maker starts a game
When the Maker starts a game
Then the Maker waits for a Breaker to join

# The second example has three steps
Scenario: Breaker joins a game
Given the Maker has started a game with the word "silky"
When the Breaker joins the Maker's game
Then the Breaker must guess a word with 5 characters

The trailing portion (after the keyword) of each step is matched to a code block, called a step definition.

Note: Some keywords are followed by a colon (:) and some are not. If you add a colon after a keyword that should not be followed by one, your test(s) will be ignored.


Keywords​

Each line that isn't a blank line has to start with a Gherkin keyword, followed by any text you like. The only exceptions are the free-form descriptions placed underneath Example/Scenario, Background, Scenario Outline and Rule lines.

The primary keywords are:

  • Feature
  • Rule (as of Gherkin 6)
  • Example (or Scenario)
  • Given, When, Then, And, But for steps (or *)
  • Background
  • Scenario Outline (or Scenario Template)
  • Examples (or Scenarios)

The secondary keywords are:

  • """ (Doc Strings)
  • | (Data Tables)
  • @ (Tags)
  • # (Comments)

Localisation: Gherkin is localised for many spoken languages; each has their own localised equivalent of these keywords.


Feature​

The purpose of the Feature keyword is to provide a high-level description of a software feature, and to group related scenarios.

The first primary keyword in a Gherkin document must always be Feature, followed by a : and a short text that describes the feature.

You can add free-form text underneath Feature to add more description. These description lines are ignored by Cucumber at runtime, but are available for reporting (they are included by reporting tools like the official HTML formatter).

Feature: Guess the word

The word guess game is a turn-based game for two players.
The Maker makes a word for the Breaker to guess. The game
is over when the Breaker guesses the Maker's word.

Example: Maker starts a game

The name and the optional description have no special meaning to Cucumber. Their purpose is to provide a place for you to document important aspects of the feature, such as a brief explanation and a list of business rules (general acceptance criteria).

The free format description for Feature ends when you start a line with the keyword Background, Rule, Example or Scenario Outline (or their alias keywords).

You can place tags above Feature to group related features, independent of your file and directory structure.

You can only have a single Feature in a .feature file.


Descriptions​

Free-form descriptions (as described above for Feature) can also be placed underneath Example/Scenario, Background, Scenario Outline and Rule.

You can write anything you like, as long as no line starts with a keyword.

Descriptions can be in the form of Markdown — formatters including the official HTML formatter support this.


Rule​

The (optional) Rule keyword has been part of Gherkin since v6.

The purpose of the Rule keyword is to represent one business rule that should be implemented. It provides additional information for a feature. A Rule is used to group together several scenarios that belong to this business rule. A Rule should contain one or more scenarios that illustrate the particular rule.

# -- FILE: features/gherkin.rule_example.feature
Feature: Highlander

Rule: There can be only One

Example: Only One -- More than one alive
Given there are 3 ninjas
And there are more than one ninja alive
When 2 ninjas meet, they will fight
Then one ninja dies (but not me)
And there is one ninja less alive

Example: Only One -- One alive
Given there is only 1 ninja alive
Then they will live forever ;-)

Rule: There can be Two (in some cases)

Example: Two -- Dead and Reborn as Phoenix
...

Example​

This is a concrete example that illustrates a business rule. It consists of a list of steps.

The keyword Scenario is a synonym of the keyword Example.

You can have as many steps as you like, but we recommend 3-5 steps per example. Having too many steps will cause the example to lose its expressive power as a specification and documentation.

In addition to being a specification and documentation, an example is also a test. As a whole, your examples are an executable specification of the system.

Examples follow this same pattern:

  • Describe an initial context (Given steps)
  • Describe an event (When steps)
  • Describe an expected outcome (Then steps)

Steps​

Each step starts with Given, When, Then, And, or But.

Cucumber executes each step in a scenario one at a time, in the sequence you've written them in. When Cucumber tries to execute a step, it looks for a matching step definition to execute.

Keywords are not taken into account when looking for a step definition. This means you cannot have a Given, When, Then, And or But step with the same text as another step.

Cucumber considers the following steps duplicates:

Given there is money in my account
Then there is money in my account

This might seem like a limitation, but it forces you to come up with a less ambiguous, more clear domain language:

Given my account has a balance of £430
Then my account should have a balance of £430

Given​

Given steps are used to describe the initial context of the system — the scene of the scenario. It is typically something that happened in the past.

When Cucumber executes a Given step, it will configure the system to be in a well-defined state, such as creating and configuring objects or adding data to a test database.

The purpose of Given steps is to put the system in a known state before the user (or external system) starts interacting with the system (in the When steps). Avoid talking about user interaction in Given's. If you were creating use cases, Given's would be your preconditions.

It's okay to have several Given steps (use And or But for number 2 and upwards to make it more readable).

Examples:

  • Mickey and Minnie have started a game
  • I am logged in
  • Joe has a balance of £42

When​

When steps are used to describe an event, or an action. This can be a person interacting with the system, or it can be an event triggered by another system.

Examples:

  • Guess a word
  • Invite a friend
  • Withdraw money

Imagine it's 1922: Most software does something people could do manually (just not as efficiently). Try hard to come up with examples that don't make any assumptions about technology or user interface. Imagine it's 1922, when there were no computers. Implementation details should be hidden in the step definitions.

Then​

Then steps are used to describe an expected outcome, or result.

The step definition of a Then step should use an assertion to compare the actual outcome (what the system actually does) to the expected outcome (what the step says the system is supposed to do).

An outcome should be on an observable output. That is, something that comes out of the system (report, user interface, message), and not a behaviour deeply buried inside the system (like a record in a database).

Examples:

  • See that the guessed word was wrong
  • Receive an invitation
  • Card should be swallowed

While it might be tempting to implement Then steps to look in the database — resist that temptation! You should only verify an outcome that is observable for the user (or external system), and changes to a database are usually not.

And, But​

If you have successive Given's or Then's, you could write:

Example: Multiple Givens
Given one thing
Given another thing
Given yet another thing
When I open my eyes
Then I should see something
Then I shouldn't see something else

Or, you could make the example more fluidly structured by replacing the successive Given's or Then's with And's and But's:

Example: Multiple Givens
Given one thing
And another thing
And yet another thing
When I open my eyes
Then I should see something
But I shouldn't see something else

* (Asterisk)​

Gherkin also supports using an asterisk (*) in place of any of the normal step keywords. This can be helpful when you have some steps that are effectively a list of things, so you can express it more like bullet points where otherwise the natural language of And etc might not read so elegantly.

Scenario: All done
Given I am out shopping
* I have eggs
* I have milk
* I have butter
When I check my list
Then I don't need anything

Background​

Occasionally you'll find yourself repeating the same Given steps in all of the scenarios in a Feature.

Since it is repeated in every scenario, this is an indication that those steps are not essential to describe the scenarios; they are incidental details. You can literally move such Given steps to the background, by grouping them under a Background section.

A Background allows you to add some context to the scenarios that follow it. It can contain one or more Given steps, which are run before each scenario, but after any Before hooks.

A Background is placed before the first Scenario/Example, at the same level of indentation.

Feature: Multiple site support
Only blog owners can post to a blog, except administrators,
who can post to all blogs.

Background:
Given a global administrator named "Greg"
And a blog named "Greg's anti-tax rants"
And a customer named "Dr. Bill"
And a blog named "Expensive Therapy" owned by "Dr. Bill"

Scenario: Dr. Bill posts to his own blog
Given I am logged in as Dr. Bill
When I try to post to "Expensive Therapy"
Then I should see "Your article was published."

Scenario: Dr. Bill tries to post to somebody else's blog, and fails
Given I am logged in as Dr. Bill
When I try to post to "Greg's anti-tax rants"
Then I should see "Hey! That's not your blog!"

Scenario: Greg posts to a client's blog
Given I am logged in as Greg
When I try to post to "Expensive Therapy"
Then I should see "Your article was published."

Background is also supported at the Rule level:

Feature: Overdue tasks
Let users know when tasks are overdue, even when using other
features of the app

Rule: Users are notified about overdue tasks on first use of the day
Background:
Given I have overdue tasks

Example: First use of the day
Given I last used the app yesterday
When I use the app
Then I am notified about overdue tasks

Example: Already used today
Given I last used the app earlier today
When I use the app
Then I am not notified about overdue tasks

You can only have one set of Background steps per Feature or Rule. If you need different Background steps for different scenarios, consider breaking up your set of scenarios into more Rules or more Features.

Tips for using Background​

  • Don't use Background to set up complicated states, unless that state is actually something the client needs to know.
    • For example, if the user and site names don't matter to the client, use a higher-level step such as Given I am logged in as a site owner.
  • Keep your Background section short.
    • The client needs to actually remember this stuff when reading the scenarios. If the Background is more than 4 lines long, consider moving some of the irrelevant details into higher-level steps.
  • Make your Background section vivid.
    • Use colourful names, and try to tell a story. The human brain keeps track of stories much better than it keeps track of names like "User A", "User B", "Site 1", and so on.
  • Keep your scenarios short, and don't have too many.
    • If the Background section has scrolled off the screen, the reader no longer has a full overview of what's happening. Think about using higher-level steps, or splitting the *.feature file.

Scenario Outline​

The Scenario Outline keyword can be used to run the same Scenario multiple times, with different combinations of values.

The keyword Scenario Template is a synonym of the keyword Scenario Outline.

Copying and pasting scenarios to use different values quickly becomes tedious and repetitive:

Scenario: eat 5 out of 12
Given there are 12 cucumbers
When I eat 5 cucumbers
Then I should have 7 cucumbers

Scenario: eat 5 out of 20
Given there are 20 cucumbers
When I eat 5 cucumbers
Then I should have 15 cucumbers

We can collapse these two similar scenarios into a Scenario Outline.

Scenario outlines allow us to more concisely express these scenarios through the use of a template with < >-delimited parameters:

Scenario Outline: eating
Given there are <start> cucumbers
When I eat <eat> cucumbers
Then I should have <left> cucumbers

Examples:
| start | eat | left |
| 12 | 5 | 7 |
| 20 | 5 | 15 |

Examples​

A Scenario Outline must contain one or more Examples (or Scenarios) section(s). Its steps are interpreted as a template which is never directly run. Instead, the Scenario Outline is run once for each row in the Examples section beneath it (not counting the first header row).

The steps can use <> delimited parameters that reference headers in the examples table. Cucumber will replace these parameters with values from the table before it tries to match the step against a step definition.

You can use parameters in Scenario Outline descriptions as well.

You can also use parameters in multiline step arguments.


Step Arguments​

In some cases you might want to pass more data to a step than fits on a single line. For this purpose Gherkin has Doc Strings and Data Tables.

Doc Strings​

Doc Strings are handy for passing a larger piece of text to a step definition.

The text should be offset by delimiters consisting of three double-quote marks on lines of their own:

Given a blog post named "Random" with Markdown body
"""
Some Title, Eh?
===============
Here is the first paragraph of my blog post. Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
"""

In your step definition, there's no need to find this text and match it in your pattern. It will automatically be passed as the last argument in the step definition.

Indentation of the opening """ is unimportant, although common practice is two spaces in from the enclosing step. The indentation inside the triple quotes, however, is significant. Each line of the Doc String will be dedented according to the opening """. Indentation beyond the column of the opening """ will therefore be preserved.

Doc strings also support using three backticks as the delimiter:

Given a blog post named "Random" with Markdown body
```
Some Title, Eh?
===============
Here is the first paragraph of my blog post. Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
```

It's possible to annotate the DocString with the type of content it contains. You specify the content type after the triple quote:

Given a blog post named "Random" with Markdown body
"""markdown
Some Title, Eh?
===============
Here is the first paragraph of my blog post. Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
"""

Data Tables​

Data Tables are handy for passing a list of values to a step definition:

Given the following users exist:
| name | email | twitter |
| Aslak | aslak@cucumber.io | @aslak_hellesoy |
| Julien | julien@cucumber.io | @jbpros |
| Matt | matt@cucumber.io | @mattwynne |

Just like Doc Strings, Data Tables will be passed to the step definition as the last argument.

Table Cell Escaping​

If you want to use a newline character in a table cell, you can write this as \n. If you need a | as part of the cell, you can escape it as \|. And finally, if you need a \, you can escape that with \\.

Data Table API​

Cucumber provides a rich API for manipulating tables from within step definitions. See the Data Table API reference for more details.


Spoken Languages​

The language you choose for Gherkin should be the same language your users and domain experts use when they talk about the domain. Translating between two languages should be avoided.

This is why Gherkin has been translated to over 70 languages.

Here is a Gherkin scenario written in Norwegian:

# language: no
Funksjonalitet: Gjett et ord

Eksempel: Ordmaker starter et spill
Når Ordmaker starter et spill
Så må Ordmaker vente på at Gjetter blir med

Eksempel: Gjetter blir med
Gitt at Ordmaker har startet et spill med ordet "bløtt"
Når Gjetter blir med på Ordmakers spill
Så må Gjetter gjette et ord på 5 bokstaver

A # language: header on the first line of a feature file tells Cucumber what spoken language to use — for example # language: fr for French. If you omit this header, Cucumber will default to English (en).

Some Cucumber implementations also let you set the default language in the configuration, so you don't need to place the # language header in every file.


Source: cucumber.io/docs/gherkin/reference — Last updated Jan 26, 2025


Writing Good Gherkin​

These conventions govern how Gherkin is generated and reviewed in this project.

Step semantics​

KeywordPurposeMust NOT contain
GivenEstablish preconditions and contextUser actions
WhenDescribe the single triggering actionAssertions
ThenAssert observable outcomesRules, formulas, filtering logic

Then steps must only assert what can be observed externally — state changes, returned data, or persisted records. They must never describe how the system decides what to process, which rows are eligible, or what formula it uses. Those are implementation details, not outcomes.

Wrong — formula embedded in a Then step:

Then the job should calculate each selected lot using:
| field | formula |
| adjusted_unit_cost | max(original_unit_cost - reduction_amount_per_share, 0) |

Wrong — selection rules embedded in a Then step:

Then the job should select only lots where:
| condition |
| settlement_date is on or before the capital reduction effective_date |

Right — assert the outcome, document the rule in the Rule: description:

Then the adjusted lot costs should be:
| lot_id | original_unit_cost | adjusted_unit_cost | remaining_quantity | adjusted_total_cost |
| L001 | 600.00 | 597.50 | 1000 | 597500.00 |

Each scenario must describe one interaction flow: context, one action phase, and one outcome phase. Use exactly one explicit When step and one explicit Then step per scenario; use And/But for continuations inside that same phase. Do not return to When after a Then. If the flow needs another action/assertion pair, split it into another scenario.


Rule: blocks​

Use Rule: to group related scenarios under a named business rule. The Rule: description (free prose before the first Example:) is the canonical home for:

  • Eligibility / selection criteria
  • Calculation formulas
  • Business constraints and rationale

This keeps steps clean while keeping the rules visible in reports and living documentation.

Feature: Corporate action cost recalculation

Rule: Capital reductions adjust lot cost only for positions settled on or before the effective date

Eligibility: lot.stock_id matches the event, lot.settlement_date <= effective_date, lot.remaining_quantity > 0
Formula: adjusted_unit_cost = max(original_unit_cost - reduction_amount_per_share, 0)
adjusted_total_cost = remaining_quantity * adjusted_unit_cost

Scenario: Capital reduction recalculates inventory cost from prior settlement history
Given ...
When ...
Then the adjusted lot costs should be:
| lot_id | adjusted_unit_cost | adjusted_total_cost |
| L001 | 597.50 | 597500.00 |

Rule: Adjusted unit cost cannot go below zero

A reduction larger than the original cost floors at zero; inventory cannot carry negative cost.

Scenario: Reduction amount exceeding original cost results in zero unit cost
...

Rule: Lots with zero remaining quantity are excluded from recalculation

Scenario: Fully liquidated lots are skipped even if settled before the effective date
...

Multiple Rule: blocks per feature are valid and encouraged. Each rule groups the scenarios that illustrate it. Rules appear in Cucumber reports and living-documentation tools.

Rule: supports its own Background: that applies only to scenarios within that rule block.


Behavior coverage ledger​

Before generating or accepting a feature file for a legacy codebase, build a source-backed behavior coverage ledger. The ledger is a checklist of meaningful behavior found in the traced source, not a generic scenario-count target.

Include every significant branch or edge case discovered from source:

  • validations and rejected inputs
  • permission, role, operator, teller, and exception-user checks
  • time-window, business-day, processing-date, and control-record checks
  • function-code, transaction-kind, status-code, flag, and mode branches
  • already-processed, duplicate, cancelled, missing-record, zero-value, overflow, and sequence-limit cases
  • partial-consumption, multi-record continuation, pagination, retry, and fallback paths
  • rollback, error-code, no-commit, no-output, and control-record write/skip behavior
  • calculations, rounding, caps/floors, cost-source selection, and eligibility filters
  • external-system, batch, message, file, table, or legacy-store side effects

Each ledger item must end in exactly one state:

  • Covered by scenario — a scenario directly demonstrates the behavior
  • Covered by Rule prose plus scenario — eligibility or formula detail is documented in the governing Rule: description and illustrated by scenario outcomes
  • Intentionally not covered — a source-backed reason explains why no scenario is needed, such as duplicate behavior or non-business plumbing tagged no-business-logic

Do not accept uncovered meaningful behavior merely because the feature already has a reasonable number of scenarios. Compact features are acceptable only when the ledger proves all important branches, edge cases, calculations, and side effects are represented.


Scenario metadata annotations​

Repave metadata uses a strict set of Gherkin comment annotations, not bare Gherkin tags. Supported annotations are # @new-scenario, # @no-api, # @id, # @from-asis, # @instruction, # @persona, # @dependency, # @entrypoint, # @views, # @apis, # @messaging, # @batch-jobs, # @legacy-data-stores, # @nfr, # @external-systems, # @code-ref, # @tables, and # @storedProcs. Deprecated interface-style annotations # @ui, # @api, # @batch, and # @interface are invalid.

Every saved scenario must be governed by exactly one meaningful Rule: block. Rule-level annotations go immediately above the Rule: line; scenario-level annotations go immediately above the Scenario: line.

Each scenario must resolve to exactly one effective # @entrypoint annotation, either on the governing rule or on the scenario. If both rule and scenario define @entrypoint, validation fails; there is no override behavior. The named registry entry must already exist before save.

Use # @nfr only for source-backed non-functional requirements: measurable or enforceable quality attributes, runtime constraints, security controls, compliance/audit obligations, retention policies, compatibility promises, operability/observability requirements, or performance/reliability targets. Do not create NFR registry entries for ordinary functional behavior such as import/export, search, pagination, save/edit/delete, field validation, business formulas, eligibility rules, ORM mechanics, framework choices, or reusable implementation paths. Those belong in scenarios, Rule: prose, entrypoint/resource annotations, table/store references, or dependencies.

Before adding a missing NFR, verify that it has a quality dimension, scope beyond merely naming the functional outcome, source evidence, a target or explicit constraint where available, and an independent verification method. Add source references, target, and verification details when using repave nfrs add.

# @entrypoint: {"type":"ui","name":"Payment Review View"}
# @views: ["Payment Review View"]
# @apis: [{"name":"Ledger Posting API","role":"calls"}]
# @messaging: [{"name":"Payment Approved Event","role":"publishes"}]
# @batch-jobs: [{"name":"Nightly Settlement","role":"affected-by"}]
Rule: Approved payments publish settlement events

# @code-ref: {"file":"legacy/payment.cbl","lines":["120-188"]}
Scenario: Manager approves a valid high-value payment

Rule-level resource annotations are inherited by all scenarios under the rule. Scenario-level resource annotations add to inherited annotations and are deduplicated by registry name. Deprecated # @ui, # @api, # @batch, and # @interface annotations hard-fail; migrate them to # @entrypoint plus plural involvement annotations.

Regular Cucumber tags such as @smoke and @critical are still allowed when they are intended as tags rather than Repave metadata.

Two of those plain tags are mandatory in Repave's own features/ directory — and only there. They say nothing about a customer project's Gherkin and are not Repave metadata:

  • @browser — the scenario needs a real browser. Its binding test must import Playwright.
  • @agent — the scenario needs an agent run through the SDK fixture. Its binding test must use test/integration/ai-sdk-harness.ts.

scenario-binding-repo-guard.test.ts fails when a tagged scenario's binder cannot reach that level, and guesses at the level from the step text when a scenario carries neither tag. Tagging is how you state the requirement instead of leaving it to a heuristic. See .


To-be Gherkin annotation grammar and registry realms​

Registry entries for views, APIs, batch jobs, and messaging destinations carry a realm: LEGACY entries are the discovered as-is inventory; MODERNIZED entries form the to-be catalogs (View Catalog, API Catalog, Batch Catalog, Messaging Catalog) that define the modernized app's interface surface. As-is Gherkin validates its registry references against the LEGACY realm; to-be Gherkin (a feature with generated to-be content) validates against the MODERNIZED realm. Create modernized entries with repave <registry> add --realm modernized, optionally linking back with --legacy-ref "<legacy name>".

@external-systems is realm-aware through the existing disposition model rather than a realm column: as-is Gherkin validates against the legacy external systems; to-be Gherkin validates against KEEP-disposition legacy systems plus the modernized external systems (the replacement targets configured on the External Systems page). @legacy-data-stores and @nfr remain realm-agnostic project-wide facts.

To-be scenarios must additionally satisfy these rules, validated on every save. Validation is unconditional and whole-document (#594): there is no per-project mode, and every scenario is revalidated on every save.

A violation no longer rejects the write. writeFeatureGherkin repairs what it can and stores the document either way, returning the rest as warnings on the write result. This replaced a gate that could freeze a project's stored specification indefinitely: every adoption of a developer's commit was refused, so the scenario list and test report kept showing text nobody was running. A document arriving from a commit or a finished agent run cannot usefully be refused — refusing cannot un-write it. Repairs applied automatically: same-named Rule: blocks fold into one, empty rules are dropped, a Rule-less scenario is placed under a Rule named for the feature, @code-ref is stripped from a to-be scenario (in any spelling or position the parser accepts), and @new-scenario is supplied where no as-is link is claimed. The one exception is the Repave CLI's whole-document To-Be writes, which refuse @code-ref so the agent learns the rule — see . Findings with no safe repair — step-phase order, catalog/registry violations, duplicate or malformed ids — are reported and the text is stored exactly as written. The rules below are therefore what the store holds, not a condition of entry. See .

The three refusals that remain are about two things disagreeing, never about content quality: OptimisticLockError and GherkinContentConflictError (someone else wrote first) and NonCanonicalGherkinInputError (the tested document is not the one being published).

  • # @id: TOBE-<n> — exactly one stable, feature-unique scenario ID per scenario. Like as-is ASIS-<nnn> ids, these are platform-backed: every to-be write through writeFeatureGherkin mints an id for any scenario lacking one (mintToBeScenarioIds) — ids are never renumbered, fresh ids come from the document's high-water mark, and a full rewrite carries ids forward by identical scenario title. Duplicate or malformed ids are not repaired; they are reported as a warning rather than rejecting the write. Because ids are carried forward by title, a scenario's title is its identity across a rewrite, and an implementation agent must never edit one. Publication rejects a tested document that no longer carries a canonical scenario's title, reporting a changed title as a rename — pairing on # @id and naming the before and after — rather than as a missing scenario. The check is one-directional: it walks the canonical titles, so it catches a title that changed or vanished, not one the tested document added. Cucumber binds steps by step text and never by scenario name, so a renamed scenario passes every test and fails only at publication; Examples placeholders in particular belong in the steps and the Examples table, never appended to a Scenario Outline title .
  • # @from-asis: <ASIS-id>[, <ASIS-id>...] or # @new-scenario — every to-be scenario either traces to one or more as-is scenarios or is explicitly new; never both. A scenario that declares # @new-scenario has nothing to resolve, so it saves even when the feature has no as-is Gherkin at all (requirements-born features) or an as-is that carries no ids yet. As-is scenarios always carry matchable ids: every LEGACY-realm write path (repave features create, repave scenarios/rules on a pre-to-be feature, repave features write-as-is-gherkin, and pre-to-be write-gherkin) mints a stable # @id: ASIS-<nnn> for any scenario lacking one — ids are never renumbered, and a full as-is rewrite carries ids forward by identical scenario title. @from-asis references must match those ids, and are verified even when the as-is document carries no ids at all (#597) — a to-be write against an unminted as-is is reported, naming scripts/backfill-asis-scenario-ids.ts --apply as the remedy. At implementation time the legacy-evidence stage resolves each link; a link matching no as-is # @id falls back to a unique exact-title match (case- and whitespace-insensitive, logged), and a scenario whose links resolve to nothing logs a warning (non-blocking since #671, previously a hard failure) rather than being silently treated as needing no evidence — implementation proceeds without a verified read of that scenario's legacy source. As-is ids are protected under refinement (#573): # @id: lines are immutable — refinement agents preserve them verbatim while titles and steps change freely — and repave features write-as-is-gherkin enforces referential integrity, rejecting any rewrite that would drop an id the feature's to-be @from-asis links reference. Pre-existing dangling links never block an as-is write, since only ids the previous as-is actually carried are compared. The To-Be tab shows each scenario's link status as a badge (As-Is linked / title match / link missing / new behavior) so drift is visible at edit time.
  • UI scenarios (@entrypoint type ui) must declare # @views: [...]. In FRONTEND_BACKEND projects they must also declare # @apis: [{"name":...,"role":"calls"}] or mark themselves # @no-api when the interaction is purely client-side. NEXTJS_FULL_STACK projects may omit @apis for server-rendered interactions.
  • # @no-api — valid only on UI scenarios (rule- or scenario-level) and contradicts @apis. Both this rule and its reciprocal (a UI scenario owing a backend contract) name the offending scenario's entrypoint in the finding — has a ui entrypoint "Student Account" but declares no backend contract and its entrypoint is api "DELETE /api/charges/{id}" . The gate is whole-document, so both findings can arrive from one save against different scenarios; without the entrypoint in the text the @no-api remedy offered to the UI scenario reads as advice about the document and gets applied to a non-UI sibling, which then trips this rule. Where there is no type to report the finding says so instead of claiming one, and distinguishes the two ways that happens: a scenario with no @entrypoint anywhere is told to declare one, while a scenario whose @entrypoint exists but cannot be parsed (bad type, missing name, extra fields) is told to fix that annotation and explicitly not to add a second — declaring one there would trip the "multiple @entrypoint annotations" rule.
  • No # @code-ref on to-be scenarios — legacy source evidence belongs on the as-is scenario; to-be scenarios trace to it via # @from-asis. The implementation pipeline resolves legacy code refs transitively (to-be @from-asis → as-is # @id → that scenario's @code-ref). repave scenarios add/replace (backing the To-Be tab's Add/Edit Scenario actions) checks the no-@code-ref rule and the @from-asis/@new-scenario requirement on the block being written, and since #595 also routes through writeFeatureGherkin, so an incremental scenario write is held to the same whole-document gate as repave features write-tobe-gherkin. Two consequences of that routing: a scenario add/replace/delete now stamps gherkinScenarioUpdatedAt (so it marks a UI prototype stale and counts as a change for re-close eligibility, neither of which it did before), and the Scenario-table sync runs inside the write's transaction, so a sync failure rolls the write back instead of being logged and ignored. repave scenarios delete passes a violation baseline: because removing a scenario cannot introduce a violation, it is only held to "no NEW violation", which keeps cleanup possible — otherwise a document with two violating scenarios could have neither deleted, each rejected for the other. repave features submit-tests enforces the same rule when it syncs a web-IDE session's tested .feature file back to the to-be Gherkin — a synced scenario is held to the exact same bar as one added through the To-Be tab. Every write path that reaches writeFeatureGherkin (repave features write-tobe-gherkin, repave scenarios/rules, the UI Gherkin editors, proposal acceptance, worktree sync) therefore applies the identical rules: the changed/new scenarios are checked first so the error names them specifically, then the whole document is revalidated.
  • Ask before you write: --dry-run. repave rules add|replace, repave scenarios add|replace and repave features write-tobe-gherkin accept --dry-run, which runs the identical validation chain — block checks, assembled-document checks, registry references, DB objects, and the whole-document to-be gate — reports the same findings, and stores nothing. It also prints the document that would have been stored (normalized, repaired, id-minted), which is the real question: not "is this block valid" but "what will this do to my feature". Since a write is never refused, only adopted-with-warnings, so without this the only way to find out why a block is rejected was to write it and read the response — which is how an agent once left five scratch Rules in a client's specification. The guarantee that makes it worth trusting: dry run and real write share one prepare step (prepareFeatureGherkinWrite), so they cannot report differently about the same input. See .
  • All @entrypoint/@views/@apis/@messaging/@batch-jobs names must exist in the MODERNIZED catalogs. Names are never auto-registered from annotations. New REST API Catalog entries must be defined in OpenAPI group files under modernized/api/ and imported with repave apis import-openapi --realm modernized <openapi-file> before saving to-be Gherkin. The imported OpenAPI file is preserved as the source of truth and catalog rows are derived from it. Other modernized catalog entries are created with their registry-specific repave <registry> add --realm modernized ... commands.
  • # @tables — for RDBMS targets, to-be @tables references are validated against the DB Table Catalog (schema-qualified ModernizedDbTable entries, e.g. public.orders) rather than the discovered legacy tables. New tables are created with full column/key definitions, either through the to-be generation modernizedTables structured output or with repave db tables add --realm modernized. MONGODB targets skip table validation entirely.

When the database modernization policy is enabled, table and stored procedure annotations follow the effective object disposition instead of the generic default:

  • RETAIN schema objects are referenced as discovered legacy schema.table / schema.view runtime targets in to-be # @tables. When a to-be scenario traces to an as-is scenario with retained schema annotations, those retained objects must be carried forward into the to-be annotations.
  • MODERNIZE schema objects must be referenced through the Modernized DB Table Catalog.
  • RETAIN stored procedures/functions stay in to-be # @storedProcs when carried by traced as-is scenarios; implementation must call them through backend repository/adapter code behind a modern UI/API/batch contract.
  • MODERNIZE, REPLACE, and RETIRE stored procedures/functions must not be referenced as retained to-be runtime dependencies.

Name REST APIs in the API Catalog canonically as <METHOD> <path> (e.g. GET /orders/{id}); name views as concise page names (e.g. Order Details Page).


Quality dimensions​

When generating or reviewing Gherkin, verify these dimensions:

  1. Specificity — Then steps use concrete values, not vague assertions like "should be saved successfully"
  2. Completeness — a source-backed behavior coverage ledger accounts for all significant code paths, branches, edge cases, calculations, and side effects
  3. Step semantics — Given = context, When = action, Then = observable outcome; no rules or formulas in Then
  4. Rule usage — business rules, eligibility criteria, and formulas are documented in Rule: description blocks, not embedded in steps
  5. Single flow — each scenario has one explicit When phase and one explicit Then phase; use And/But for continuations and split a second action/assertion pair into another scenario
  6. Business language — steps use domain/product terms, not internal implementation details (column names, stored procedure names, internal flags)
  7. Data tables — used when there are 3+ fields or multi-row assertions; not required for simple 1-2 field steps
  8. Scenario Outline — used only when the same scenario logic runs across genuinely different parameterized cases
  9. Background — repeated Given steps across all scenarios in a feature or rule are extracted into Background:
  10. Code coverage — @code-ref annotations cover the complete call chain: entry point → business logic → data layer
  11. Annotation accuracy — @entrypoint, @views, @apis, @messaging, @batch-jobs, @tables, @storedProcs, @legacy-data-stores, @nfr, and @external-systems match what was verified in source. View, API, messaging, batch, data-store, external-system, and NFR annotations must use existing project registry names.

Editing a Closed Feature​

A feature whose status is completed or merged is locked: every Gherkin mutation is refused rather than silently overwriting shipped work. The refusal is HTTP 409 with code: "FEATURE_LOCKED" and the feature's featureStatus.

Passing a confirmation reopens the feature and applies the edit in the same write:

SurfaceHow to confirm
App UIThe confirmation prompt shown on the blocked action
repave CLI--confirm-reopen on the write command
Direct API call"confirmReopen": true in the request body
# Refused: the feature is merged.
repave features write-tobe-gherkin feature-123 ./checkout.feature

# Reopened and applied.
repave features write-tobe-gherkin --confirm-reopen feature-123 ./checkout.feature

The flag is available on the reopen-gated write commands: features write-tobe-gherkin, features write-as-is-gherkin, features create, features create-from-requirements, features update, scenarios add|replace|update|delete, and rules add|replace.

Two things to know before confirming:

  • The reopen is tracked, not silent. reopenedFromStatus records the status it was reopened from, so the feature can be re-closed to exactly that status later.
  • An edit makes it un-re-closable. Because the reopen lands in the same write as the Gherkin change, reopenedAt is recorded as null, which marks the feature as changed since the reopen — so it cannot be re-closed to its previous status. Reopening to look is reversible; reopening to edit is not.
  • An As-Is correction counts as a change, on its own. write-as-is-gherkin stamps asIsGherkinUpdatedAt, which the re-close guard reads alongside gherkinScenarioUpdatedAt. (Those two are the whole guard: the UI prototype was dropped from it, because a prototype is a design artifact that no longer reopens a feature and so must not block closing one.) It matters most for the reopen that arrives before the edit — refining the As-Is on a locked feature reopens it at dispatch (re-closable, since a run that fails writes nothing) and the agent's correction lands later. Once a To-Be exists the write no longer mirrors into gherkinScenario, so before this stamp existed that correction moved no timestamp at all and the feature could be re-closed to merged holding an As-Is it never merged (#530). The As-Is stamp deliberately does not mark a UI prototype stale — the prototype is derived from the To-Be, which an As-Is correction does not touch.

Automated agents are exempt: an agent token skips the gate entirely (there is no human to prompt), so agent-driven flows never need the flag and never trip the 409.

Three routes go further and reopen a locked feature for agents too, skipping only the prompt: features update, and features create / features create-from-requirements when given an explicit --id (which makes them an upsert of an existing feature). The reopen is recorded the same way as a confirmed one — reopenedFromStatus set, reopenedAt null — so it is traceable, and un-re-closable for the ordinary reason above: the Gherkin changed in the same write. This matters for the agent flows that rewrite existing features wholesale — features split, combine, and rearrange/coverage-refinement jobs, whose dispatch routes are not themselves reopen-gated: running one over a merged feature leaves it reopened rather than still merged.

The remaining CLI write routes — features write-tobe-gherkin, write-as-is-gherkin, scenarios, rules — now answer the agent case the same way, and for the same reason (). Until that fix they left an agent-written feature's status alone: a merged feature stayed merged with its content replaced and nothing recorded, so the drift was not even detectable afterwards. Whether a write replaces the whole document or one scenario was never what decided this (write-tobe-gherkin replaces the entire To-Be document either way) — the two groups had simply drifted apart. All eight routes now share one rule, held in editStatusWrite / overwriteStatusWrite rather than copied per route.

A feature under review is the exception, and is reopened by nobody. "Locked" and "reopenable" are not the same set: in_review means a pull request is open, and that status is the only handle the platform has on it — the PR poller finds features to reconcile by it, and a merge-mode switch is blocked while any feature holds it. Reopening such a feature to analyzed would orphan a live pull request, and an edit-bearing reopen (reopenedAt: null) could never be undone. So an agent write leaves the status exactly where it is and schedules the same FEATURE_PR refresh the feature page's own action uses, bringing the open pull request up to date with what was just written. A burst of writes schedules one refresh, not one per write. Humans are still refused outright on a feature under review and told to close the pull request first — see isReopenableLockedStatus.

What a tracked agent reopen buys is provenance, not recovery. An edit-bearing reopen is never re-closable by design, so the point is that the row records where it came from. Until the two create routes wrote status: 'analyzed' unconditionally and recorded the reopen only for non-agents, so an agent upsert over a merged feature left reopenedFromStatus unset — the feature became indistinguishable from one that had never been closed. reclose refused before the fix and still refuses after it; what changed is that the history is now legible. A test asserted the unset value as expected behaviour, which is why the bug survived a review of the sibling routes.

The response reports the status the feature actually ended up in. A request's own status field is not authoritative on a locked feature — repave features update sends analyzed on every call regardless of the target, so the reopen decides it instead.