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:
FeatureRule(as of Gherkin 6)Example(orScenario)Given,When,Then,And,Butfor steps (or*)BackgroundScenario Outline(orScenario Template)Examples(orScenarios)
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 (
Givensteps) - Describe an event (
Whensteps) - Describe an expected outcome (
Thensteps)
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
Thensteps 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
Backgroundto 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.
- For example, if the user and site names don't matter to the client, use a higher-level step such as
- Keep your
Backgroundsection short.- The client needs to actually remember this stuff when reading the scenarios. If the
Backgroundis more than 4 lines long, consider moving some of the irrelevant details into higher-level steps.
- The client needs to actually remember this stuff when reading the scenarios. If the
- Make your
Backgroundsection 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.
- 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
- Keep your scenarios short, and don't have too many.
- If the
Backgroundsection 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*.featurefile.
- If the
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
| Keyword | Purpose | Must NOT contain |
|---|---|---|
Given | Establish preconditions and context | User actions |
When | Describe the single triggering action | Assertions |
Then | Assert observable outcomes | Rules, 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 usetest/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.
writeFeatureGherkinrepairs what it can and stores the document either way, returning the rest aswarningson 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-namedRule:blocks fold into one, empty rules are dropped, a Rule-less scenario is placed under a Rule named for the feature,@code-refis stripped from a to-be scenario (in any spelling or position the parser accepts), and@new-scenariois supplied where no as-is link is claimed. The one exception is the Repave CLI's whole-document To-Be writes, which refuse@code-refso 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-isASIS-<nnn>ids, these are platform-backed: every to-be write throughwriteFeatureGherkinmints 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# @idand 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;Examplesplaceholders in particular belong in the steps and theExamplestable, never appended to aScenario Outlinetitle .# @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-scenariohas 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/ruleson a pre-to-be feature,repave features write-as-is-gherkin, and pre-to-bewrite-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-asisreferences 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, namingscripts/backfill-asis-scenario-ids.ts --applyas the remedy. At implementation time the legacy-evidence stage resolves each link; a link matching no as-is# @idfalls 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 — andrepave features write-as-is-gherkinenforces referential integrity, rejecting any rewrite that would drop an id the feature's to-be@from-asislinks 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 (
@entrypointtypeui) must declare# @views: [...]. InFRONTEND_BACKENDprojects they must also declare# @apis: [{"name":...,"role":"calls"}]or mark themselves# @no-apiwhen the interaction is purely client-side.NEXTJS_FULL_STACKprojects may omit@apisfor 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 contractandits 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-apiremedy 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@entrypointanywhere is told to declare one, while a scenario whose@entrypointexists but cannot be parsed (badtype, missingname, 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-refon 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-refrule and the@from-asis/@new-scenariorequirement on the block being written, and since #595 also routes throughwriteFeatureGherkin, so an incremental scenario write is held to the same whole-document gate asrepave features write-tobe-gherkin. Two consequences of that routing: a scenario add/replace/delete now stampsgherkinScenarioUpdatedAt(so it marks a UI prototype stale and counts as a change for re-close eligibility, neither of which it did before), and theScenario-table sync runs inside the write's transaction, so a sync failure rolls the write back instead of being logged and ignored.repave scenarios deletepasses 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-testsenforces the same rule when it syncs a web-IDE session's tested.featurefile 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 reacheswriteFeatureGherkin(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|replaceandrepave features write-tobe-gherkinaccept--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-jobsnames 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 undermodernized/api/and imported withrepave 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-specificrepave <registry> add --realm modernized ...commands. # @tables— for RDBMS targets, to-be@tablesreferences are validated against the DB Table Catalog (schema-qualifiedModernizedDbTableentries, e.g.public.orders) rather than the discovered legacy tables. New tables are created with full column/key definitions, either through the to-be generationmodernizedTablesstructured output or withrepave db tables add --realm modernized.MONGODBtargets 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:
RETAINschema objects are referenced as discovered legacyschema.table/schema.viewruntime 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.MODERNIZEschema objects must be referenced through the Modernized DB Table Catalog.RETAINstored procedures/functions stay in to-be# @storedProcswhen carried by traced as-is scenarios; implementation must call them through backend repository/adapter code behind a modern UI/API/batch contract.MODERNIZE,REPLACE, andRETIREstored 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:
- Specificity —
Thensteps use concrete values, not vague assertions like "should be saved successfully" - Completeness — a source-backed behavior coverage ledger accounts for all significant code paths, branches, edge cases, calculations, and side effects
- Step semantics —
Given= context,When= action,Then= observable outcome; no rules or formulas inThen - Rule usage — business rules, eligibility criteria, and formulas are documented in
Rule:description blocks, not embedded in steps - Single flow — each scenario has one explicit
Whenphase and one explicitThenphase; useAnd/Butfor continuations and split a second action/assertion pair into another scenario - Business language — steps use domain/product terms, not internal implementation details (column names, stored procedure names, internal flags)
- Data tables — used when there are 3+ fields or multi-row assertions; not required for simple 1-2 field steps
- Scenario Outline — used only when the same scenario logic runs across genuinely different parameterized cases
- Background — repeated
Givensteps across all scenarios in a feature or rule are extracted intoBackground: - Code coverage —
@code-refannotations cover the complete call chain: entry point → business logic → data layer - Annotation accuracy —
@entrypoint,@views,@apis,@messaging,@batch-jobs,@tables,@storedProcs,@legacy-data-stores,@nfr, and@external-systemsmatch 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:
| Surface | How to confirm |
|---|---|
| App UI | The 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.
reopenedFromStatusrecords 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,
reopenedAtis recorded asnull, 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-gherkinstampsasIsGherkinUpdatedAt, which the re-close guard reads alongsidegherkinScenarioUpdatedAt. (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 intogherkinScenario, so before this stamp existed that correction moved no timestamp at all and the feature could be re-closed tomergedholding 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.