The acceptance scenario contract
Status: design of record, mostly shipped. The scenario tree, the Cucumber-JVM runner and the nightly job exist (ADR 2026-10-11-3). This page designs the four additions in ADR 2026-10-13-1: the scenario contract (A), the per-pull-request check (B), the evidence bundle (C) and the fixture builders (D) are built. Ownership and the absorption of engineering tests are in ADR 2026-10-15-1.
Reader: a maintainer or contributor who writes, reviews or generates scenarios under
tests/scenarios/, or who changes the acceptance-jvm/ runner or the nightly job.
Two layers, one link
Verification has two layers with different owners, formats and tooling.
| Layer | What it holds | Where it lives | Format |
|---|---|---|---|
| Product promises | What a user can rely on, at product level, with its owner, status and history | spec-kitty-qa (outside this repository) | Markdown pages with structured front matter |
| Acceptance scenarios | Executable examples that check the CLI does what a promise says | tests/scenarios/<area>/*.feature in this repository |
Gherkin, run by Cucumber-JVM |
| Engineering tests | Unit, integration, acceptance-gate and e2e tests | tests/ |
pytest |
The promise layer and the scenario layer are deliberately not one format. Each repository picks the scenario tooling that suits its stack, and promise pages stay readable by people who never open a repository. Nothing in this repository reads, parses or copies promise pages.
The only link between the layers is the promise identifier, carried as a tag:
@promise:PR-CLI-INIT-001
Scenario: Re-running init keeps the project's files
The identifier is opaque here. This repository checks that the tag is well formed and nothing else: it does not know whether the identifier exists, what it says, or whether it is ratified. spec-kitty-qa resolves identifiers, computes coverage and reports gaps. The identifier is the persistent one, never the human-readable slug, so renaming a promise page never touches a scenario.
This keeps the open-core repository free of product-planning content: a scenario says what the CLI does, and an identifier is meaningless without the promise store.
flowchart LR
subgraph QA["spec-kitty-qa"]
P["Promise pages<br/>(identifier, slug, owner, status)"]
G["Scenario drafting<br/>(operator-driven)"]
R["Trace lint + Allure report"]
end
subgraph Core["spec-kitty (this repository)"]
S["tests/scenarios/**.feature<br/>@promise:<id>"]
C["Per-PR scenario check<br/>(Python, no JVM)"]
N["Nightly acceptance-bdd<br/>(Cucumber-JVM, 3 OS)"]
E["Evidence bundle<br/>per OS"]
end
P --> G -->|"pull request: @wip scenarios"| S
S --> C
S --> N --> E -->|"artifact download"| R
R -.->|"resolves identifiers"| P
Figure: promise pages stay in spec-kitty-qa; scenarios, the per-PR check, the nightly and its evidence live here; spec-kitty-qa reads the evidence back and resolves the identifiers.
A. The scenario contract
Layout (built). One folder per CLI area under tests/scenarios/, one or more .feature
files per folder. The runner reads them in place.
Tag vocabulary. Tags are the whole interface between a scenario and everything outside it.
| Tag | Meaning in this repository | Rule the check enforces |
|---|---|---|
@promise:<id> |
Links the scenario to a promise. Inert at run time. | <id> matches [A-Za-z0-9][A-Za-z0-9._-]*; no tag repeats on one scenario. |
@wip |
Steps are not bound yet, or the scenario is not trusted yet. Runs in the non-gating pass. | Needs @wip-owner:<handle> and @wip-since:<YYYY-MM-DD>. |
@manual |
Never runs automatically (heavy or on-demand). | None. |
@defect:<issue> |
Reproduces a tracked bug. | <issue> is a positive integer. |
@qa:<ref> |
Traceability to a QA-corpus scenario it was converted from. | <ref> uses the identifier character set. |
@<area> and other bare tags |
Free-form selection tags. | None. |
Untagged and @promise-only scenarios gate the nightly, as today. How old a @wip scenario may
be is a reporting rule for spec-kitty-qa's trace lint, not a check here: the per-PR check only
requires the date to be present and valid.
Drafted scenarios. A scenario drafted from a promise, by hand or with AI help, lands as
@wip with the promise tag, an owner and a date. It may use step text that has no binding yet.
An engineer binds or rewords the steps, adds the positive control
(acceptance scenarios guide) and removes
@wip. The tag is added by a person; tooling never invents a promise identifier.
B. The per-pull-request scenario check (built)
Without it, the nightly would be the only thing that reads scenarios, so a malformed feature file or a typo'd step would be found the next morning. The check finds it on the pull request, without a JDK.
- Where:
tests/architectural/test_scenario_contract.py, Python standard library plus first-partykernel.clock(no third-party dependency), with a small Gherkin reader for the subset the scenarios use (Feature,Background,Scenario,Scenario Outline,Examples,Rule, tags, steps, doc strings and data tables). No new dependency. - What it asserts:
- Every
.featurefile parses and holds at least one scenario (NO_SCENARIOSotherwise, which catches a misspeltScenario:keyword), and every scenario has at least one step. - Every tag follows the vocabulary table above.
- Every step of a scenario without
@wipmatches exactly one step definition. Step definitions are read from the@Given/@When/@Thenannotations inacceptance-jvm/src/test/java/**/steps/*.java; the Cucumber expression parameters in use ({string},{int},{word}) become regular expressions. An unsupported parameter type fails the check, so the reader cannot silently fall behind the runner. @wipscenarios may use unmatched steps; the check lists them as information.
- Every
- Oracles: one broken fixture per rule under
tests/architectural/fixtures/scenarios/, and a guard that the real tree is not empty. - Routing: the
scenarios(tests/scenarios/**) andacceptance_jvm(acceptance-jvm/**) groups inci-router.ymlstart gating the job that runs this file. Contributors still need no JDK.
C. The evidence bundle (built)
Each nightly leg hands its results to spec-kitty-qa. A result is only useful there if it says which CLI build, on which OS, in which pass produced it. The current upload does not.
Problems it fixed (the job had these before the bundle was built):
- The gating pass and the
@wippass write into the samebuild/folders, and the second pass runs with--rerun-tasks, so it overwrites the gating pass's Cucumber JSON and JUnit XML and mixes both passes' Allure results before the upload. - The Allure results folder is not set, so the upload lists two possible folders and only warns if both are empty.
- Nothing records the CLI version, the commit, the OS or the pass.
- The artifact has no explicit retention.
- The job's
if:mentionspull_request, an eventci-nightly.ymlnever receives.
Design:
- Each pass writes to its own folder,
acceptance-jvm/build/acceptance/<pass>/with<pass>set togatingorwip, holdingallure-results/(set throughallure.results.directory),cucumber.jsonandjunit/. A Gradle property selects the pass, and the tag filter comes from the pass, so CI cannot pair a pass name with the wrong filter. - Before the scenarios run, the runner writes:
allure-results/environment.properties: CLI version (from the wheel it built), commit SHA, OS, JDK version, pass;allure-results/executor.json: the CI run URL and run number, when present.
- After both passes, the job writes
acceptance-jvm/build/acceptance/evidence.json:schema_version, commit SHA, CLI version, OS, the run URL, and per pass the outcome, scenario counts and the result paths. spec-kitty-qa reads this file first; a field it does not know never breaks it, and aschema_versionit does not know makes it refuse. - The upload keeps the name
acceptance-bdd-results-<os>, uploads onlyacceptance-jvm/build/acceptance/, fails when the gating results are missing (if-no-files-found: error), and setsretention-days: 30. - The job's
if:drops thepull_requestclause.
D. Fixture builders (built)
Today's Arrange steps can set up an initialised project and a git repository with history. The promises that matter next (mission creation, the status lane walk, review and consolidation) need a project with a mission in a known state.
- Builders stay black-box. A builder in
kitty.acceptance.environmentreaches a state only by running the CLI through the driver (init,agent mission create,agent mission finalize-tasks,move-task, ...). It never writes CLI-managed state, so a builder breaks when the CLI's own path breaks, which is the point. It may write what an agent or a user authors (see "What a builder may write" below). - Steps on top:
Given a project with a mission "<slug>",Given the work package "<wp>" of mission "<slug>" is in lane "<lane>", and assert steps onspec-kitty agent tasks status --jsonoutput. - First scenarios: a small mission-lifecycle area that proves the builders, each with a positive control.
- Built:
MissionFixture(a project with a Mission, throughinitandagent mission create),MissionLifecycle(a Mission planned on the topic branchworkup to finalized work packages, asingle_branchMission on the topic branch or minted from protectedmain, and a work package walked throughin_progress,for_review,in_reviewandapproved, or rejected with feedback), andGitStates(sparse-checkout state, linked worktrees, an in-progress merge). - What a builder may write (amended 2026-10-15, ADR 2026-10-15-1 decision 6): the planning files
an agent authors (
spec.md,plan.md,tasks.md,tasks/WP*.md), a work package's code, review feedback outside the repository and plain files a user edits in git. Never CLI-managed state:meta.json, the status log,lanes.json, review-cycle records or anything under.kittify/.
What this design does not cover
- Where promise pages live, their format, who ratifies them, and how they are versioned.
- Drafting scenarios from promises, and the QA docs site.
- Building the Allure report, keeping its history, and the trace lint.
All of these belong to spec-kitty-qa. This repository's side of that boundary is the tag vocabulary in section A and the evidence bundle in section C.