DISTILL Methodology: Acceptance Test Creation

SkillDocs & knowledge

Acceptance test creation methodology for the DISTILL wave. Domain knowledge for the acceptance designer agent: port-to-port principle, prior wave reading, wave-decision reconciliation, graceful degradation, and document back-propagation.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the DISTILL Methodology: Acceptance Test Creation skill

What this skill tells your AI

The instructions your AI receives, as published by nwave-ai/nwave in nWave/skills/nw-distill/SKILL.md and read by ahel’s review.

This skill provides the acceptance designer's methodology for creating acceptance tests. The orchestrator controls the overall flow (agent dispatch, review gate, handoff) -- this skill focuses on HOW to create good acceptance tests.

LANGUAGE CONVENTION FRAME (read FIRST — overrides all examples below)

Code examples in this skill use Python syntax for illustration only. They are NOT prescriptive about target language. nWave is language-agnostic per the "genericity and agnosticism" mandate (2026-05-24).

Before authoring ATs, detect the target project's language from these manifest files (in order):

  • package.json → TypeScript / JavaScript (jest, vitest, cucumber-js, playwright)
  • Cargo.toml → Rust (cargo test, proptest, cucumber-rust)
  • go.mod → Go (testing, ginkgo, godog)
  • pyproject.toml / setup.py / Pipfile → Python (pytest, pytest-bdd, hypothesis)
  • pom.xml / build.gradle → Java / Kotlin (JUnit5, Cucumber-JVM, jqwik)
  • *.csproj / *.fsproj → C# / F# (xUnit, SpecFlow, FsCheck)
  • Gemfile → Ruby (RSpec, Cucumber-Ruby)
  • Package.swift → Swift (XCTest, swift-testing)

When the target language is NOT Python:

  1. Adapt EVERY code example to the target language's conventions (naming, imports, type system, test-framework idioms, file extensions).
  2. Replace Python-specific imports (from pytest_bdd import ..., from hypothesis import ..., import dataclasses) with target-language equivalents (import { Given, When, Then } from '@cucumber/cucumber', import * as fc from 'fast-check', etc.).
  3. Replace Python type hints (def f(x: int) -> str) with target-language type syntax.
  4. Replace Python directory conventions (tests/, __init__.py) with target conventions (test/, __tests__/, no init files for TS/JS).
  5. Replace Python class/function syntax (class Customer:, def given_port():) with target equivalents.

Project conventions ALWAYS WIN over examples below. If the user's repo has 50 TS files using describe()/it() blocks and zero Python files, ATs MUST be TypeScript with describe()/it() — never Python pytest-bdd regardless of how authoritative this skill's examples look.

Empirical anchor: skill examples being Python-only caused LLM to infer Python conventions universal, leading to Python code emitted in greenfield TS project. Connects [[feedback_language_adapter_plugin_architecture_2026_05_24]] (genericity mandate) + F-LANGUAGE-ADAPTER-PLUGIN-INFRASTRUCTURE epic.

ADR-025 (2026-05-07) — DISTILL is canonical AT author

DISTILL produces ALL acceptance tests as scaffolded RED (skip/pending markers). DELIVER's 3-phase cycle (RED / GREEN / COMMIT, per ADR-025) does NOT re-author ATs in RED — it only unskips the scaffolds and writes PBT unit tests. Wave separation: DISTILL = "what should the system do" (ATs), DELIVER = "how" (PBT unit + impl). The pre-DELIVER fail-for-right-reason gate (described in this skill) becomes the RED phase entry/exit gate in DELIVER per ADR-025 D2.

Output Tiers (per D2)

Provenance: feature lean-wave-documentation — D2 (schema-typed sections), D10 (one-line expansion descriptions). Tier-1 [REF] sections (always emitted) + Tier-2 EXPANSION CATALOG items (lazy, on-demand) are the two output bands. The .feature file remains the SSOT for scenarios; the wave-delta sections are pointers + structured summaries. Full contract: nWave/skills/nw-density-resolution-contract/SKILL.md.

Tier-1 [REF] — always emitted

Under ## Wave: DISTILL / [REF] <Section> headings:

  • Scenario list with tags — table of scenario titles + tags (@walking_skeleton, @US-N, @real-io, @in-memory, @error, @property)
  • WS strategy — A/B/C/D selection per Mandate 5 with one-line justification
  • Adapter coverage table — per Mandate 6, every driven adapter mapped to at least one @real-io scenario
  • Scaffolds — list of RED-ready scaffold files created (per Mandate 7) with __SCAFFOLD__ markers
  • Test placement — tests/{path}/ directory choice with one-line precedent justification
  • Driving Adapter coverage — every CLI/endpoint/hook in DESIGN mapped to at least one subprocess/HTTP/hook scenario
  • Pre-requisites — DESIGN driving ports + DEVOPS environment matrix the scenarios depend on

Tier-2 EXPANSION CATALOG — lazy, on-demand (per D10)

Rendered under ## Wave: DISTILL / [WHY|HOW] <Section> only when requested via --expand <id> (DDD-2), the broad wave-end menu (expansion_prompt = "ask"), a declared trigger under expansion_prompt = "ask-intelligent", mode = "full" auto-expansion, or an ad-hoc user request mid-session.

Expansion IDTier labelOne-line description
scenario-alternatives-considered[WHY]Alternative scenario phrasings weighed and rejected (Gherkin variants, tag schemes)
fixture-design-discussion[WHY]Why these tmp_path/conftest fixtures, why these scopes, what they cannot model
edge-case-enumeration[WHY]Full edge-case taxonomy: empty/null/boundary/concurrency/timeout/permission
error-path-rationale[WHY]Why each @error scenario was chosen and what failure mode it surfaces
tagging-cookbook[HOW]Cookbook for tag application: @property, @requires_external, @walking_skeleton
scaffold-authoring-recipes[HOW]Per-language scaffold recipes (Python, TS, Go, Rust, Java) with marker conventions
pbt-strategy-notes[WHY]Property-based testing strategies for invariants surfaced by the feature
expansion-catalog-rationale[WHY]Why this set of expansions, why these defaults, why D10 enforces one-line descriptions
domain-language-fact-to-step-table[HOW]Soft gate: agent proposes fact→step-name pairs for user review before committing step-method names to code
policy-bootstrap-template[HOW]docs/architecture/atdd-infrastructure-policy.md bootstrap snippet emitted on first DISTILL in a project
tier-b-state-machine-template[HOW]State-machine PBT skeleton for Tier B in-memory journey testing (Mandate 10)

Density resolution (per D12)

Call resolve_density(global_config) from scripts/shared/density_config.py after reading ~/.nwave/global-config.json (missing/malformed = empty dict). Returns mode ("lean" | "full") + expansion_prompt ("ask" | "ask-intelligent" | "always-skip" | "always-expand" | "smart") per the D12 cascade (resolver-internal, DDD-5 — do NOT replicate locally). Branch on density.mode for what to emit; branch on density.expansion_prompt at wave end for menu behaviour. DISTILL declares no ask-intelligent triggers: emit no menu and the shared-contract no-trigger skip event; do not invent triggers. Full cascade detail, branch semantics, ad-hoc override workflow: nWave/skills/nw-density-resolution-contract/SKILL.md.

Telemetry (per D4 + DDD-6)

Every expansion choice emits a DocumentationDensityEvent (dataclass at src/des/domain/telemetry/documentation_density_event.py) via event.to_audit_event()JsonlAuditLogWriter().log_event(...). Schema fields per D4: feature_id, wave, expansion_id, choice, timestamp. For this wave the schema declares "wave": "DISTILL". Use helper scripts/shared/telemetry.py:write_density_event(...) — do NOT write JSONL directly.

Wave-specific signal: DELIVER consuming a lean DISTILL feature-delta — downstream --expand for fixture-design or edge-case enumeration indicates the [REF] baseline plus the .feature file was insufficient for the crafter. Full emission rules: nWave/skills/nw-density-resolution-contract/SKILL.md.

Feature-Delta Schema (US-01, US-02)

Provenance: unified-feature-delta US-01 (scaffold command) and US-02 (E1+E2 validator rules).

Every feature-delta.md is a Markdown document with ## Wave: <NAME> sections. The canonical table format must be used in every ### [REF] Inherited commitments block.

Scaffold command

nwave-ai init-scaffold --feature <feature-name>

Creates docs/feature/<feature-name>/feature-delta.md with three pre-populated wave sections (DISCUSS, DESIGN, DISTILL), each containing a ready-to-fill commitments table. The scaffold passes the E1+E2 validator immediately.

Canonical table format

Every ### [REF] Inherited commitments block MUST have exactly four columns in this order:

## Wave: DISCUSS

### [REF] Inherited commitments

| Origin | Commitment | DDR | Impact |
|--------|------------|-----|--------|
| n/a | <commitment text> | n/a | <impact text> |

Column semantics:

  • Origin: wave and row reference of the upstream commitment (e.g., DISCUSS#row1) or n/a for root commitments
  • Commitment: the specific commitment inherited or newly introduced in this wave
  • DDR: Design Decision Record reference that authorizes any change (e.g., DDR-3) or n/a / (none) when not applicable. (Renamed from DDD to avoid colliding with Domain-Driven Design — issue #50. The legacy DDD column and DDD-N references are still accepted during the deprecation window.)
  • Impact: substantive description (>=10 words or a consequence verb from the verb list) of the commitment's effect on the system

Validator rules (E1+E2)

  • E1 (SectionPresent): every ## Wave: <NAME> heading must match the canonical pattern. Known wave names: DISCOVER, DISCUSS, DESIGN, DEVOPS, DISTILL, DELIVER. Near-misses get a did-you-mean suggestion.
  • E2 (ColumnsPresent): every ### [REF] Inherited commitments block must have a header row with the four required columns (Origin, Commitment, DDR, Impact) in any order, case-insensitive. The legacy DDD header is also accepted.

Incremental authoring

Sections for waves not yet authored may be omitted entirely. The validator does not require all six wave sections to be present. An incremental feature-delta with only DISCUSS is valid. Missing future-wave sections are never flagged.

Acceptance Criteria: Port-to-Port Principle

Every AC MUST name the driving port (entry point) through which the behavior is exercised. This enables port-to-port acceptance tests that make TBU (Tested But Unwired) defects structurally impossible.

Each AC includes:

  1. Observable outcome: what the user/system sees
  2. Driving port: the entry point that triggers the behavior (service, handler, endpoint, CLI command)

Without the driving port, a crafter can write correct code that is never wired into the system.

Features: "When user {action} via {driving_port}, {observable_outcome}" Bug fixes: "When {trigger}, {modified_code_path} produces {correct_outcome} instead of {current_broken_behavior}"

Translating Gherkin to Property-Based Tests (state-delta + Universe)

Layer constraint (per nw-test-design-mandates Mandate 9): this recipe applies ONLY to layers 1-2 (unit, in-memory acceptance with in-memory doubles). For subprocess / FS acceptance and integration tests (layers 3+), use example-only with assert_state_delta for the universe guard (Mandate 8) — sad paths stay enumerated, never PBT-generated (Mandate 11).

Per the Paradigm Mandate (PBT + state-delta is the default for unit + in-memory acceptance), DISTILL outputs Property: framings, not classic Scenario: examples, whenever the spec is quantifiable AND the test runs at layer 1-2. Single-example Scenario: is FALLBACK — use when the property cannot be expressed (one-off regression repro) OR when the scenario runs at layer 3+ (real adapter, subprocess, integration, WS).

Recipe

  1. Take the Scenario:: identify pre-condition, action, post-condition.
  2. Identify the Universe (layer-specific — see nw-tdd-methodology Layered test discipline matrix):
    • Acceptance: use-case observable outcomes at driving port (events emitted, state on driven-port double, error class)
    • Walking Skeleton: user-visible end-to-end output (stdout, exit code, FS side-effects)
  3. Quantify the precondition: from "a feature task exists" → forall task in tasks where task.type in {feature, fix} via Hypothesis @given(strategy).
  4. Express the invariant: instead of "the row's DELIVER cell shows [in-progress] phase: GREEN", state "for every task entered into DELIVER GREEN phase, the row's DELIVER cell renders status=in-progress with phase name visible".
  5. Frame as Property: in the .feature file:
    @property @driving_port @us-XX
    Property: Operator sees in-progress phase for every task entered into a wave
      Given a workflow definition with named phases
      When for every task that emits PhaseEntered with phase=P, wave=W via the driving port
      Then the operator sees a row whose W cell renders status=in-progress with phase=P
      And the cell shape is invariant across {feature, fix, spike} task types
    
  6. Build the step-defs with @given Hypothesis strategies + assert_state_delta(before, after, universe, expected) — universe entries are port-exposed names, never internal fields.

Example (good vs bad Universe)

# GOOD — port-exposed observable Universe
universe = {
    "events.PhaseEntered.emitted_count",
    "board.rows[task_id].cells[wave].status",
    "board.rows[task_id].cells[wave].phase",
}

# BAD — internal-field Universe (refactor breaks)
universe = {
    "BoardProjection._rows_cells_dict",
    "BoardProjection._rows_workflow_columns",
}

The bad Universe couples the test to private mutation details. Renaming _rows_cells_dict to _cells_by_task reds the test for an implementation rename — a refactoring-hostile signal.

Walking Skeleton vs general acceptance

  • 1-2 @walking_skeleton @wiring_e2e scenarios per slice, via subprocess + real I/O, prove wiring once.
  • The rest of Property: scenarios run via driving-port direct invocation with in-memory doubles for driven ports (~10ms each). Fast feedback for the use-case logic.

State-machine PBT trigger (Hebert ch.11)

State machine properties are for when the model itself is a state machine, not the system. If you can describe the SUT's behaviour by a state machine model with command/postcondition pairs, use stateful PBT. If not, use regular FORALL with property-based assertions.

This sharpens the earlier heuristic ("users perceive distinct states"): the trigger is about the model shape you can write, not the user's perception. A circuit-breaker policy is a state-machine model (ok / tripped / blocked + transitions); a sort function is not, even if its internal state has phases.

Negative testing workflow (Hebert ch.6)

Hebert ch.6 — to surface under-specification, deliberately RELAX assumptions in the test (e.g., remove a precondition, widen the input domain). If the property still holds, you've over-specified. If it fails on inputs you'd expect to be valid, the spec is incomplete. Apply when an existing property never fails — the property may be vacuously true.

Workflow:

  1. Start with a happy-path property suite (positive tests).
  2. Pick one assumption the suite relies on (e.g. "prices are numeric", "items list is non-empty").
  3. Write a new property that relaxes that assumption.
  4. Run. A crash signals (a) a real bug, (b) an under-specified contract that should fail deliberately with a clear error, or (c) a place the spec needs tightening.
  5. Repeat per assumption.

The negative-testing pattern is the property-level instrument for the "inputs validated at boundaries" mandate from the production-grade quality bar.

Architecture of Reference (ports & adapters — project-level defaults)

Three classes of ports, each with a default test treatment. This table is PROJECT-LEVEL, decided once per project (typically during DESIGN of the first feature, or at framework adoption time). It is NOT renegotiated per feature. The agent applies these defaults; the per-feature decision is the MECHANISM (see Project Infrastructure Policy below), not the treatment.

Port typeExamplesDefault in test
Driving (entry point)HTTP API, CLI, in-process call, hookReal adapter (test host, CLI runner, app via DI container)
Driven internal (shared state)Repository, read model, application cacheReal adapter via the mechanism declared in the project Infrastructure Policy
Driven external / non-deterministicClock, email, SMS, push, payment, LLM, third-party APIFake/stub with output capture (so a Then can observe the side effect)

This table replaces the earlier per-feature Walking Skeleton Strategy A/B/C/D choice. The decision is structural — port CLASS implies port TREATMENT — and the per-project Infrastructure Policy specializes the mechanism for each treatment.

If a port cannot be classified by the agent, ask the user with a soft prompt — do not improvise the classification.

Project Infrastructure Policy

The Architecture of Reference fixes the port class → test treatment defaults. The Project Infrastructure Policy specializes those defaults with the concrete mechanism used in THIS codebase (Testcontainers vs dedicated env vs in-memory; which fake class). The decision is made once per project, not per feature.

File location and structure

Lives at docs/architecture/atdd-infrastructure-policy.md (project-local). Three tables, one per port class, columns: Port | Mechanism | Note.

# ATDD Infrastructure Policy

## Driving
| Port | Mechanism | Note |
|---|---|---|
| HTTP API | WebApplicationFactory<Program> | |
| CLI | subprocess from tmp_path | |

## Driven internal (real)
| Port | Mechanism | Note |
|---|---|---|
| IUserRepository (MongoDB) | Testcontainers.MongoDb, fresh db per test class | |

## Driven external / non-deterministic (fake)
| Port | Fake | Note |
|---|---|---|
| IClock | FakeClock | manual advance |
| IEmailSender | FakeEmailSender | in-memory capture |

The Note column is optional — use only when the mechanism needs a one-line clarification.

Apply-if-exists / write-if-absent

  1. File exists (default mode --policy=inherit): read the policy and apply recorded decisions. No port-by-port negotiation for ports already in the table.
  2. Port in scope is missing from the policy: ask the user with a soft prompt (one row per missing port: which mechanism for {port}?), then append the row to the policy before generating scenarios. The policy grows by accretion.
  3. File is absent: create an empty skeleton with the three section headers (use the policy-bootstrap-template expansion below), then treat every port in scope as missing (case 2).

The file is edited in place. No per-row versioning — git history is the audit trail.

--policy=fresh flag

When the user passes --policy=fresh:

  • Ignore the existing file for this run.
  • Treat every port in scope as missing (soft prompt per port).
  • On completion, rewrite the file from scratch with the newly agreed decisions.

Use fresh for major refactors (stack swap, test strategy overhaul). In all other cases, inherit is the default.

Relationship to the Architecture of Reference

The Architecture of Reference answers: "what kind of treatment does this port class get?" (real vs fake). The Project Policy answers: "and which concrete implementation does this project use for that treatment?" (Testcontainers vs dedicated env, which fake class).

The policy CANNOT override the port class defaults: a driven-internal port cannot become a fake through the policy (that requires an explicit waiver documented in distill/wave-decisions.md). The policy only records the mechanism for each default treatment.

Wave-Decision Reconciliation HARD GATE (pre-scenario)

This is the ONLY hard gate before scenario writing. Execute it BEFORE any other DISTILL work:

  1. Read all wave-decisions.md from prior waves: docs/feature/{feature-id}/discuss/wave-decisions.md, docs/feature/{feature-id}/design/wave-decisions.md, docs/feature/{feature-id}/devops/wave-decisions.md.
  2. For each DISCUSS decision, check whether DESIGN or DEVOPS contradicts. Examples: DISCUSS "email notifications" but DESIGN "in-app only" = CONTRADICTION; DISCUSS "REST API" but DESIGN "gRPC" = CONTRADICTION; DISCUSS "single-tenant" but DEVOPS "multi-tenant" = CONTRADICTION.
  3. If ANY contradiction → return {CLARIFICATION_NEEDED: true, questions: [{file, contradicting-decisions, ask-which-stands}]} and BLOCK.
  4. If zero contradictions → log "Reconciliation passed — 0 contradictions" and proceed.

Do NOT silently pick one side of a contradiction. Do NOT write scenarios against ambiguous specifications. The cost of blocking is minutes; the cost of implementing the wrong behavior is hours.

Graceful Degradation Matrix (warn vs block)

Missing artifactActionReason
docs/feature/{id}/devops/ directoryWARN, use project default infra (from Project Infrastructure Policy or sensible defaults)tests can proceed without env spec
docs/feature/{id}/discuss/ directoryWARN, derive ACs from DESIGN, skip story-to-scenario traceabilitystory traceability lost, scenarios still coherent
docs/feature/{id}/design/ directoryBLOCK — ask user to identify driving ports before writing any scenariodriving ports unknown, hexagonal boundary unverifiable

Missing artifacts trigger warnings, not failures — EXCEPT when the missing artifact makes a design mandate unverifiable (DESIGN for hexagonal boundary). In that case, BLOCK.

Two-Tier Acceptance Composition (Mandate 10 expanded for DISTILL)

Per nw-test-design-mandates Mandate 10, acceptance tests come in two tiers. DISTILL decides which tiers apply per feature.

Default — Tier A only: most features need only Tier A (Gojko-style, production composition root, 1-2 scenarios per journey).

Add Tier B when both conditions hold:

  • Feature has a journey of ≥3 chained scenarios (Pillar 2 active — the Given of N reuses N-1's Given + When), AND
  • Input space is domain-rich (emails, dates, payloads, free-text, IDs from a large set).

Skip Tier B when:

  • Feature is config-shaped (single-shot installer config, schema validation, one-off CLI), OR
  • Journey has 1-2 scenarios (Tier A example covers the space), OR
  • The only observable is "did it crash" (no state mutation to model).

File layout when both tiers are emitted

tests/{test-type-path}/{feature-id}/acceptance/
  {feature}.feature                       # Tier A — Gherkin scenarios (production DI)
  steps/
    conftest.py
    steps_{feature}.py                    # Tier A step-methods (production composition root)
  tier_b/
    test_{feature}_state_machine.py       # Tier B — RuleBasedStateMachine
    in_memory_composition.py              # InMemoryComposition (same interfaces, in-memory doubles)

Shared vocabulary contract

Both tiers invoke the same step-method names (Given_<precondition>, When_<action>, Then_<outcome>). Tier A wires them through the production composition root; Tier B wires them through InMemoryComposition. The step-method NAMES are the contract — DRY across tiers.

When DISTILL emits Tier B, it MUST verify each @rule-decorated method invokes a step-method that already exists in the Tier A steps_{feature}.py. New step-method names introduced only in Tier B are a smell — they hint the journey was modeled differently for in-memory exploration than for production wiring.

Wave: DISTILL / [HOW] Expansion Templates (lazy)

These templates are inline so the skill ships with the bootstrap snippets. They are emitted into the wave's feature-delta.md only when rendered as Tier-2 expansions (per the Density Resolution + Expansion Catalog above).

Expansion policy-bootstrap-template

Emitted on first DISTILL in a project (file absent at docs/architecture/atdd-infrastructure-policy.md):

# ATDD Infrastructure Policy

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
610
Forks
63
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
nw-distill
Source
github.com/nwave-ai/nwave