At a glance
Scenario Outlines run the same step body N times with one row of an Examples: table per run. The Gherkin spec is settled — the gap is in the porting machinery: the bdd-exec skill matches steps to L2 files by alias regex, but there's no documented contract for "the Examples row's column values reach the L2 runspec body." We have one validated upload Outline (discussion-capture.feature) whose L2 already references $FIXTURE_PATH, $FIXTURE_FILENAME, $FIXTURE_CONTENT_TYPE (vars no scenario today supplies), and three more Outlines (pii-boundaries, share-links, video-import) blocked on the same gap.
step.params. It's a per-step UPPERCASE-keyed bag that mergeStepVars() folds into the substitution scope. We do NOT need new runner schema, new dispatch code, or a new substitution pass. The work is entirely in the bdd-exec skill: read the Examples row and emit one plan per row with column values stamped into step.params. The runner stays oblivious to Outlines — it sees N independent plans, same as today.
What we are NOT building: a new YAML schema, a new substitution grammar, a new $VAR scope, a new dispatch phase, or a "scenario outline" awareness in the runner. The smallest viable change is "the skill writes a few more keys into the existing params bag." Authors put the fixture path directly in the Examples block as another column — no second YAML file, no frontmatter declaration, no lookup machinery. (A lookup-table escape hatch was considered and explicitly deferred; see §11.1.)
Scenarios this unblocks (and which to implement alongside the framework PR)
Implementing this plan means — in the same PR or its immediate follow-up — flipping these specific scenarios from @pending to validated. They share the same authoring shape: an Examples block with explicit columns, no hidden lookup. Each row below is one scenario or Outline row that should be running end-to-end against test.sageox.ai before the cluster is considered shipped.
| Feature file | Scenario / Outline row | Examples columns it needs |
|---|---|---|
discussions/discussion-capture.feature | Uploaded MP3 becomes a playable, transcribed discussion | format, media_kind, expected_mime, fixture_path, fixture_filename, fixture_content_type |
| Uploaded WAV becomes a playable, transcribed discussion | ||
| Uploaded M4A becomes a playable, transcribed discussion | ||
| Uploaded OGG becomes a playable, transcribed discussion | ||
| Uploaded FLAC becomes a playable, transcribed discussion | ||
| Uploaded MP4 becomes a playable, transcribed discussion | ||
| Uploaded MOV becomes a playable, transcribed discussion | ||
| Uploaded WebM becomes a playable, transcribed discussion | ||
| Uploaded MKV becomes a playable, transcribed discussion | ||
| Live-recorded WebM clip becomes a playable, transcribed discussion (sister scenario, not Outline row) | single hardcoded row | |
discussions/discussion-capture.feature | Pasting a transcript yields a viewable, summarised discussion (sister scenario) | n/a (no Outline; depends on this PR only insofar as it shares the recording-cluster BAs) |
discussions/discussion-browsing.feature | Non-member can browse a public team's discussion list | depends on the upload Outline running first (creates the discussion to browse); no direct columns |
| Non-member can open a discussion detail page on a public team | ||
| Non-member is blocked from a private team's discussion list | ||
| Non-member is blocked from a private team's discussion detail | ||
content-import/video-import.feature | Importing MP4 from Loom URL produces a ready discussion | source, format, fixture_url (for URL sources), fixture_path (for local-file sources) |
| Importing MP4 from direct video URL produces a ready discussion | ||
| Importing MP4 from local file produces a ready discussion | ||
| Importing M4A from local file produces a ready discussion | ||
| Importing MP3 from local file produces a ready discussion | ||
security/pii-boundaries.feature | Anonymous viewer sees no PII on Overview | page (single column) |
| Anonymous viewer sees no PII on Sessions | ||
| Anonymous viewer sees no PII on Repos | ||
| Anonymous viewer sees no PII on Discussions | ||
security/pii-boundaries.feature | Public API /api/v1/public/teams/{teamId}/info contains no PII | endpoint (single column) |
Public API /api/v1/public/teams/{teamId}/recordings contains no PII | ||
Public API /api/v1/public/teams/{teamId}/merge-candidates contains no PII | ||
sharing/share-links.feature | Anonymous share returns a resolvable URL | no Outline; depends on this PR only for the Background's upload-a-recording BA (which needs fixture columns to dispatch) |
| Resolving a revoked share returns 4xx | ||
| Anonymous viewer of an authenticated share is sent to login | ||
| Expired share returns 4xx after its TTL elapses | ||
| Single-use share is rejected on the second resolve | ||
| Old token rejects, new token resolves | ||
| Anonymous share lifecycle for recording (Outline row 1 of 3) |
Count: ~30 scenarios flip from @pending to validated in PR 1's footprint. The session and discussion entity-type rows of the share-links Outline (rows 2–3) remain @pending because they're blocked on session-lifecycle and discussion-import BAs entirely — not on this substitution work. They'll flip when those clusters land.
/bdd-exec against test.sageox.ai. The lint mode (T19) gains the new rules from §7; the worked example in §10 is the canonical authoring reference for new Outlines added later. The fixture S3 bucket (§8) is provisioned and seeded before the recording-journey rows can pass.Existing-machinery check (the suspicious read)
Before designing new machinery, I confirmed what's already in the runner. The plan is shaped by what I found.
| File | What's already there | Implication |
|---|---|---|
tests/bdd/runner/src/types.ts (lines 78–109) |
StepParamsSchema: per-step UPPERCASE-keyed bag of string/number/boolean. Reserved keys (BDD_*) rejected at parse. Documented as an "escape hatch." |
This is already the carrier for "scenario-specific override of a $VAR." Outline columns ARE scenario-specific overrides — same mechanism, different source. |
tests/bdd/runner/src/runner.ts (lines 565–595) |
mergeStepVars() merges step.params over shared (persona) vars, BEFORE trusted runtime vars (which can't be overridden). Already type-coerces non-strings via String() / JSON.stringify(). |
Substitution scope already accepts per-step overrides. No new merge step needed. |
tests/bdd/runner/src/dispatch.ts (lines 152–187) |
substituteTree(operation.body, stepVars) walks the body and replaces every $VAR from the merged bag. substituteAssertion does the same for asserts with jq-quoting. |
The substitution code is already var-bag-driven. The same code path handles persona defaults, exports, and params — column values are just more entries in the bag. |
tests/bdd/runner/src/lint.ts (lines 120–125) |
Lint pass already marks step.params keys as known starting at that step. |
If we stamp column values into params, lint validates them for free. |
tests/acceptance/business-actions/upload-a-recording.md (lines 96–110) |
The L2 body already references $FIXTURE_PATH, $FIXTURE_FILENAME, $FIXTURE_CONTENT_TYPE. Today no scenario supplies them — the contract is written for a substitution mechanism that doesn't yet exist on the emit side. |
The Outline-side gap is real and the L2 author knew it. The contract names the vars; the skill is the missing link. |
params escape hatch was designed for one-off overrides (e.g., forcing a duplicate email). It happens to be the right shape for Outline columns, where each row IS a set of one-off overrides. So the work is to teach the skill to write params from a richer source, not to invent a new mechanism. If a draft of this plan ever proposes a "new substitution layer" or a new RunPlan field, it's wrong — push back.
1. Substitution semantics — column header as $<HEADER_UPPER>
Every column header in an Examples: block is injected verbatim as $<HEADER_UPPER> into the substitution scope for that row's plan. One column → one var. No transformation, no derivation, no second YAML file. If a verb needs three fixture-related vars, the Examples block has three columns — explicit and visible in the feature file.
format and media_kind — putting fixture_path alongside them is a small Tufte-style increase in data density, not a leak of infrastructure into the spec. This simplification is reversible (see §11.1); we'd revisit it once 3+ feature files duplicate the same column-to-fixture mapping.
The substitution rules, in one paragraph
For each Examples row, the skill computes the row's params bag:
- For each
(header, value)in the row, addHEADER_UPPER → valueto the bag.format→FORMAT;expected_mime→EXPECTED_MIME;fixture_path→FIXTURE_PATH. Whitespace in the column value is preserved verbatim (one trim of leading/trailing space, no internal collapse). - Stamp the resulting bag into
step.paramsfor EVERY step in the Outline's plan emit. (Step-scoped params won't break — the merge code inrunner.tshandles N steps × M keys.)
Headers are uppercased once at emit time and never re-substituted. The runner's existing $VAR grammar (uppercase, underscore-allowed) handles them transparently.
Edge cases (worth stating up front)
| Case | Behavior |
|---|---|
Column header with spaces (| expected mime |) | Reject at lint time. Headers must match /^[a-z][a-z0-9_]*$/. Author the column as expected_mime. |
Column value referenced ONLY in step text (<page> appears in the When but no L2 body uses $PAGE) | Still stamped into params. The skill substitutes <page> into stepText at emit time (cosmetic, for reports) AND into params (functional). The L2 may or may not consume it. |
Column value contains $ or { characters | Allowed; values are plain strings, never re-interpreted. The substituteTree code does ONE substitution pass — no recursion. |
L2 body references a $VAR with no matching column header (and no persona/runtime/export source) | Lint ERROR (§7). Halts the run before any VM spawns. |
| Same column twice in two Examples blocks under one Outline | Each row emits its own plan, so blocks don't interact. The skill iterates blocks then rows. |
2. Where substitution happens — emit-time, via params
The skill substitutes at plan-emit time, into the existing step.params bag. The runner does not need to know Outlines exist.
| Option | Verdict | Reasoning |
|---|---|---|
(i) Emit-time inline — substitute column values directly into step.spec.operations[].body, no params |
rejected | Forces the skill to surgically rewrite the YAML body for every Outline row. Diverges N plans where today the YAML is shared. Lint loses the "did the row supply X?" signal — the value is just baked in. Bad for triage. |
(ii) Runner-side — skill emits scenarioOutlineRow on the plan; runner substitutes during dispatch |
rejected | New schema field, new runner code path, new test surface. mergeStepVars already does the work; we'd be re-deriving it in a less-tested place. Plus: dispatch.ts is already complex (~600 lines); adding scenario-shape semantics there muddies the layering. |
(iii) Emit-time via step.params — skill computes the row bag from the Examples columns and stamps it into every step's params |
selected | Zero new schema. Zero new runner code. Lint already validates these as known vars (lint.ts:120–125). The "did the row supply X?" signal lives in params verbatim — easy triage. One plan per row, runner sees them as independent scenarios. This is what the existing escape hatch was designed for; we're just using it. |
fast Given (precondition setup) and verification Then (assertion). Putting the bag on every step is cheap (small JSON, no runtime cost) and saves us asking "which steps need which subset?" The runner doesn't care.
4. Format-to-BA dispatch (the live-recording row)
Recording-journey has one Outline whose body is one phrasing — "she uploads a <format> file titled X" — but where one row ("live recording") needs to route to a different BA (record-live-discussion) than the others (upload-a-recording). Today's matcher alias-matches the step text once per Outline; it has no per-row override.
With no lookup table to carry an _ba override, the cleanest path is structural separation in the feature file: keep the upload Outline focused on file-upload formats, and lift live-recording out into a sister scenario (plain Scenario:, not Outline) right next to it. The user-facing spec gains one extra block and loses an awkward "one row that means something different from the others" footnote; the AI authoring path stops needing to remember a per-row dispatch escape hatch.
| Option | Verdict |
|---|---|
(A) Per-row Gherkin alias override (custom @-tag on the Examples row, or a !!ba:foo sigil in the cell) |
rejected — invents Gherkin syntax. AI authoring will hallucinate it back as standard. Tools (linters, IDEs, gherkin-parsers in the broader ecosystem) don't know it. |
(B) Split the Outline: keep file-upload formats in one Outline, lift live-recording into a sister Scenario: in the same feature file |
selected — no new syntax, no new dispatch code, the spec reads more honestly ("uploading a file" and "recording live in the browser" are different acts that happen to share a destination). Step text for the live-recording scenario reads "she records a live discussion titled X", alias-matches record-live-discussion.md directly. Zero skill changes needed for dispatch. |
(C) A control field in the Examples block (e.g. an _ba column the skill specially interprets) |
rejected — reintroduces a lookup-shaped escape hatch through a different door. Spec readers wouldn't know _ba is structural; AI authors would invent variations. |
The structural split is paid for once, in the feature file, by the human author who already knows the two acts are different. It costs nothing at runtime and nothing in the skill.
5. Backward compatibility
The four currently-passing scenarios (onboarding/registration.feature × 2, context-injection/agent-priming.feature × 2) do not use Outlines. They use plain Scenario: blocks with quoted-string captures only. The substitution rules in §1 are additive: a scenario with no Examples block emits an empty row-derived params bag, which is a no-op merge in mergeStepVars.
| Surface | Risk | Mitigation |
|---|---|---|
| Existing non-Outline scenarios | None — skill code path for Outlines is gated on the parsed AST node type | Phase 1 includes a regression run of the 4 passing scenarios |
| Existing L2 frontmatter | None — no new fields required | — |
Existing step.params shape | None — same schema, just populated from one more source | — |
Hand-written smoke plans that already use params | None — skill populates Outline-derived keys; smoke scripts continue setting their own keys | Keys can't collide because Outline column names are author-controlled and smoke scripts know their own bag |
| The lint pass | Low — new ERROR types (§7) are additive | Ships with new lint rules behind the same dry-run path; no new flag |
Migration path for the ~24 blocked Outlines
Most blocked Outlines unblock with column-as-VAR alone:
- pii-boundaries (7 rows) — single
pagecolumn, no fixtures. - share-links recording row (1 of 3) — column-only.
- recording-journey audio + video (9 rows) — fat Examples block with explicit
fixture_path,fixture_filename,fixture_content_typecolumns alongsideformat,media_kind,expected_mime. - recording-journey live-recording (1 row) — split into a sister
Scenario:per §4. - video-import (5 rows) — fat Examples block with explicit fixture columns.
The session/discussion rows of share-links remain @pending because they're blocked on session-journey and discussion-import BAs entirely — that's an L2 gap, not a substitution gap.
6. Skill update scope (tests/bdd/skills/bdd-exec/SKILL.md)
Phase 6 (RunPlan emit) — section currently at lines 422–547
Today Phase 6 says (lines 442–445):
"The ONLY things you substitute at plan-emit time are scenario-table literals: alias-regex captures, Scenario Outline Examples row values, and persona/pronoun resolution into the persona field."
Update to:
- The skill MUST iterate Examples rows for any
Scenario Outline:, emitting one full RunPlan per row. - For each row, the skill builds a row params bag: add
HEADER_UPPER → valuefor every column. - The bag stamps into
step.paramson EVERY step of the row's plan (Background steps included, since some Backgrounds reference column vars indirectly). - The skill MUST also substitute
<header>tokens instepTextat emit time (cosmetic — the report renders the substituted text, which matches what humans expect from Outlines).
Add a new sub-heading after Phase 6's "Authoring tips" (around line 547): "Scenario Outline expansion" — ~20 lines of contract per the above.
Phase 7 (runner dispatch) — section at lines 549–599
No change. The runner sees one plan per row, indistinguishable from a plain Scenario. The only edit is to refresh the comment at line 446 that lists "scenario-table literals" — change "Scenario Outline Examples row values" from "literal substitution into the spec body" to "stamped into step.params for runner-side substitution."
Phase 2 (step resolution) — lines 158–225
Unchanged. The matched L2 for a step doesn't vary per row; the same alias-match flow as today applies. Outline rows differ only in their params bag.
Phase 4.5 (runspec extract+validate) — lines 262–284
Unchanged. Each row's plan validates independently.
7. Lint mode (T19) extensions
The dry-run lint already catches forward-refs, bare-$VAR-on-RHS, jq-parse failures, and reserved-key shadowing (lint.ts:83–250). The Outline substitution introduces three new failure modes; all become ERROR-severity rules (one stays WARN), all run during the existing single-pass walk (no new dry-run flag).
| Rule | Trigger | Message format |
|---|---|---|
L1 Unresolved $VAR in L2 body |
An L2 body references a $VAR that is NOT a persona default / runtime var / export / Outline column header for the row |
ERROR step N / <l2>: references $FIXTURE_PATH but no Examples column, persona default, export, or runtime var supplies it. Add a fixture_path column to the Examples block. |
| L2 Orphan column | An Examples column header is uppercased to a var no L2 body or assert in the row's plan references | WARN outline column <col>: stamped as $<COL>_UPPER but no step body or assert references it (typo? unused column?) — WARN not ERROR because the column may appear in step text only (legitimate) |
| L3 Invalid header | An Examples column header contains spaces, dashes, or starts with a digit | ERROR outline column "<raw>": column headers must match /^[a-z][a-z0-9_]*/. Rename to e.g. <suggestion>. |
L1 is load-bearing — without it, an author who forgets a fixture column gets a confusing "substitution failed" mid-VM-spin instead of a one-line pre-run rejection.
8. Fixture story (the physical-world question)
The recording-journey Outline uploads MP3/WAV/M4A/OGG/FLAC/MP4/MOV/WebM/MKV files to S3 via presigned URL. Real bytes. Where do they come from?
Three options, scored
| Option | Storage cost | Speed | Determinism | Verdict |
|---|---|---|---|---|
| Fetch from a pinned S3 bucket at run time | 0 in git; ~50MB in S3 (<$0.01/mo standard storage + negligible GET costs at our test cadence). | Fast — one parallel aws s3 cp in the orchestrator's bootstrap pulls all clips in ~1–3s on us-west-2 (same region as the test bench & the prod buckets). Subsequent runs in the same orchestrator process hit a local cache. |
Deterministic via per-fixture SHA pin written next to the URL in the Examples block (or a sibling manifest); the orchestrator validates SHA on download. | selected |
Commit fixtures to git LFS under tests/acceptance/fixtures/media/ |
~50–100MB in LFS bandwidth, billed per pull. Adds an LFS dependency for every dev who clones the repo, including ones who never touch acceptance tests. | Fast on warm checkout. ~5–15s LFS pull on cold CI checkout, billed against the GitLab LFS bandwidth quota on every CI run. | Deterministic — LFS pointer pins the SHA. | Rejected — LFS bandwidth costs scale with CI run count (every PR pays); the dev-experience tax (LFS install) hits everyone. S3 cost scales with storage only. |
| Generate on the fly with ffmpeg before each run | 0 in git. | Slow — ~2–10s per format × N formats, every test run. | Deterministic only if ffmpeg version is pinned in the orchestrator image; otherwise vulnerable to ffmpeg-codec drift. | Rejected — slow, and ffmpeg-version drift is a future flaky-test source. |
us-west-2 alongside the test bench, and the data path is well-understood. LFS adds a parallel storage system that solves only the fixture-distribution problem and adds bandwidth cost on every CI pull. The single counter-argument — "S3 adds an offline-failure mode" — is moot for BDD runs: they already require test.sageox.ai to be reachable, so they're online by construction.
The plan
- Create a pinned S3 bucket:
sageox-bdd-fixturesinus-west-2. Public-read on the fixture objects (they're synthetic public-domain clips, not customer data); IAM-gated on writes (a single S3:PutObject role for the dev who regenerates fixtures). - Upload one short clip per format we test (MP3/WAV/M4A/OGG/FLAC/MP4/MOV/WebM/MKV) at canonical key paths:
s3://sageox-bdd-fixtures/media/<sha256>/<name>.<ext>. The SHA in the key path doubles as integrity check — if the bytes ever change, the object lives at a different key. Each clip is 2–4s, mono / 480p where applicable, long enough for the transcript pipeline to produce something. - The orchestrator's bootstrap step fetches all needed fixtures via
aws s3 cp(or signed GET) into the VM's/fixtures/media/directory. Parallel + region-local, so the cold-cache cost is ~1–3s for the whole set. - The Examples block's
fixture_pathcolumn is the in-VM path (e.g.,/fixtures/media/sample.mp3) — written explicitly per row. The orchestrator's fixture-fetch logic is responsible for ensuring those paths exist before any verb runs; missing-file errors surface there, not at the verb'sfs.read. - A one-time
scripts/regenerate-fixtures.shdocuments how the seed clips were generated (ffmpeg invocations against public-domain source URLs) and how to re-upload to the canonical SHA-keyed paths. The script is NOT a runtime dependency. - Local dev: the bootstrap step caches downloaded fixtures under
tests/bdd/.fixture-cache/<sha256>/(gitignored) so a secondmake testhits the cache, not the network. Cache-miss path is the sameaws s3 cp. - CI: the workflow's AWS-credentials step already exists for the rest of the test bench (per the env's IAM role); the bootstrap just adds the fixture-pull command before the first /bdd-exec.
- Auth: public-read on the bucket means the
aws s3 cpcan be plaincurlagainst the S3 HTTPS URL — no AWS SDK / credentials needed in the VM. Saves a credentials-plumbing step inside the shuru microVMs.
.mov defeats the test — the API would 415 on real production traffic. The fixture bytes MUST match the declared contentType, which means real per-format clips.
9. Phasing — one PR, optional second
-
PR 1: Column-as-VAR substitution + LFS-tracked media fixtures
Skill change: for each Outline row, stamp
HEADER_UPPER → valueinto every step'sstep.params. Substitute<header>into stepText for report rendering. Phase 6 of SKILL.md updated; new "Scenario Outline expansion" sub-section added.Lint rules L1 (unresolved
$VAR), L2 (orphan column), L3 (invalid header) land.Fixture work:
tests/acceptance/fixtures/media/with LFS-tracked clips; orchestrator VM image bind-mount; CIgit lfs pullstep. Feature files updated to fat Examples blocks with explicitfixture_path,fixture_filename,fixture_content_typecolumns where needed. Recording-journey live-recording split into a sisterScenario:per §4.Unblocks: pii-boundaries (7 rows), share-links recording row (1 of 3), recording-journey audio + video (9 rows), video-import (5 rows), recording-journey live-recording (1 row). Validation criterion: end-to-end run of pii-boundaries (no fixtures) and discussion-capture recording-journey Outline (full fixture path) both green; LFS pull works in CI.
~150 lines of skill changes + ~50MB of LFS fixtures + 1 orchestrator image change (bind mount) + feature-file edits.
-
PR 2 (optional): polish & cleanup
If PR 1's lint-rule surface area or fixture-mount path needs revision after first contact, ship the polish here. Otherwise the work is done in PR 1.
Held in reserve; not pre-planned.
PR 1 alone delivers all 23/24 unblockable rows of value. The session/discussion rows of share-links remain @pending pending unrelated L2 work.
10. Worked examples
Example A: recording-journey audio Outline (fat Examples with fixture columns)
Source feature (excerpted from tests/acceptance/features/discussions/discussion-capture.feature) — note the explicit fixture columns:
Scenario Outline: Uploaded <format> becomes a playable, transcribed discussion
Given Tina is a member of "Acme Engineering"
When she uploads a <format> file titled "Architecture Review"
And processing completes
Then "Architecture Review" should appear in the team's discussions list as Ready
And it should have a <media_kind> element with a non-empty src and no error state
And its stored MIME type should be <expected_mime>
Examples: Audio formats
| format | media_kind | expected_mime | fixture_path | fixture_filename | fixture_content_type |
| MP3 | audio | audio/mpeg | /fixtures/media/sample.mp3 | sample.mp3 | audio/mpeg |
| WAV | audio | audio/wav | /fixtures/media/sample.wav | sample.wav | audio/wav |
plan emit for MP3 row (today)
{
"feature": "features/discussions/discussion-capture",
"scenario": "Uploaded MP3 becomes a playable, transcribed discussion",
"persona": "tina",
"runId": "run_...",
"steps": [
/* Given Tina is a member ... */
{
"phase": "when",
"stepText": "she uploads a MP3 file titled \"Architecture Review\"",
"spec": {
"operations": [{
"verb": "web/recording/upload-composite/upload",
"body": {
"teamId": "$TEAM_ID",
"filePath": "$FIXTURE_PATH", // unresolved
"filename": "$FIXTURE_FILENAME", // unresolved
"contentType": "$FIXTURE_CONTENT_TYPE", // unresolved
"title": "Architecture Review"
},
"exports": { "RECORDING_ID": ".data.recordingId" }
}]
}
// NO params block - dispatch will throw SubstitutionError
}
]
}
Fails at dispatch: substitution: required var FIXTURE_PATH is not set.
plan emit for MP3 row (after)
{
"feature": "features/discussions/discussion-capture",
"scenario": "Uploaded MP3 becomes a playable, transcribed discussion",
"persona": "tina",
"runId": "run_...",
"steps": [
/* Given Tina is a member ... - same params bag here too */
{
"phase": "when",
"stepText": "she uploads a MP3 file titled \"Architecture Review\"",
"spec": { "operations": [{
"verb": "web/recording/upload-composite/upload",
"body": {
"teamId": "$TEAM_ID",
"filePath": "$FIXTURE_PATH",
"filename": "$FIXTURE_FILENAME",
"contentType": "$FIXTURE_CONTENT_TYPE",
"title": "Architecture Review"
},
"exports": { "RECORDING_ID": ".data.recordingId" }
}] },
"params": {
"FORMAT": "MP3",
"MEDIA_KIND": "audio",
"EXPECTED_MIME": "audio/mpeg",
"FIXTURE_PATH": "/fixtures/media/sample.mp3",
"FIXTURE_FILENAME": "sample.mp3",
"FIXTURE_CONTENT_TYPE": "audio/mpeg"
}
}
// Then steps carry the same params; assert
// .data.stored_mime == "$EXPECTED_MIME" works
]
}
Passes: substituteTree resolves every $VAR from the merged bag. WAV row emits an identical-shape plan with WAV-derived values. Every value in the params bag came directly from the Examples row — no derivation, no lookup.
Example B: pii-boundaries Overview row (single column)
Scenario Outline: Anonymous viewer sees no PII on <page>
When an unauthenticated viewer opens the <page> of "Acme Engineering"
Then the rendered page should contain no email addresses
Examples:
| page |
| Overview |
| Sessions |
| Repos |
| Discussions |
L2 for the When step (business-actions/view-public-team-surface.md) takes one var:
```yaml runspec=primary
operations:
- verb: web/anon/open-team-page
body:
teamSlug: $TEAM_SLUG
surface: $PAGE
exports:
RENDERED_HTML: .data.html
```
plan emit for Overview row (today)
// Skill can pre-substitute <page> into the body literal,
// but only because it's a one-column case. The Then steps reference
// $RENDERED_HTML which IS exported, but $PAGE shows up nowhere in the
// L2 body today - so the only path is hard-coding "Overview" into the
// body during emit:
{
...
"spec": { "operations": [{
"verb": "web/anon/open-team-page",
"body": {
"teamSlug": "$TEAM_SLUG",
"surface": "Overview" // baked in - breaks if L2 author
// ever wants to introspect $PAGE in
// an assert
}
}] }
}
plan emit for Overview row (after)
{
...
"spec": { "operations": [{
"verb": "web/anon/open-team-page",
"body": {
"teamSlug": "$TEAM_SLUG",
"surface": "$PAGE" // unchanged from L2 source
}
}] },
"params": {
"PAGE": "Overview"
}
}
// Sessions, Repos, Discussions rows emit the same shape
// with PAGE: "Sessions" / "Repos" / "Discussions".
The cheapest end-to-end demonstration of the substitution path. One column, one stamped var, no fixtures, no special-case dispatch.
11. What this is NOT
- Not a multi-persona mechanism. Scenarios that need a second actor in scope still emit one persona per plan. That's a separate gap — bd-#27.
- Not a "data-driven test" generalization. We're only handling Gherkin's standard
Examples:blocks. No JSON/CSV external data sources, no random-input generation. - Not a fix for the session/discussion rows of share-links. Those rows remain @pending because the L2s for "Tina has a session" and "Tina has a discussion" don't exist. The substitution work routes through them once they land, but doesn't author them.
- Not a verb-side change. The
upload-compositeverb already acceptsfilePath / filename / contentType; the work is plumbing scenario data to those inputs, not changing the verb contract. - Not a runner refactor. No new schema fields, no new dispatch phases, no new substitution grammar. The runner is unaware Outlines exist before this change and remains unaware after it.
- Not a UI-recorder-mode fix for live-recording. The sister-scenario split (§4) routes that case to
record-live-discussion; whether that BA'sprimaryrunspec actually drives the web recorder through Stagehand vs. mocks the result is the BA's problem, not the substitution layer's. - Not a lookup-table mechanism. We deliberately deferred the lookup-table escape hatch — see §11.1.
- Not in scope for migration to
cucumber-jsor a third-party Gherkin runtime. The skill remains the parser-and-emitter; the runner stays single-scenario. If we ever swap to cucumber-js, the column-as-VAR semantics described here will already match the standard Examples-row binding, so we'd lose nothing.
11.1 Deferred: lookup-table escape hatch
An earlier draft of this plan proposed a second mechanism on top of column-as-VAR: a per-BA YAML lookup table where one column value (e.g., format: MP3) would expand into multiple derived vars (fixture path, filename, MIME, etc.). It's not in this plan, but the sketch is preserved here so a future plan-author doesn't have to re-derive it.
When this would become necessary
- The same
format → fixture_path / fixture_filename / fixture_content_typemapping starts appearing in 3+ feature files. (Currently: one.) - The fixture path columns start materially hurting feature-file readability for non-engineer reviewers. (Currently: the fat Examples block is a Tufte-style win, not a loss.)
- Someone wants to refactor the fixture catalog (e.g., move
/fixtures/media/→/fixtures/audio/) and the change has to ripple through N feature files. (Currently: one feature file, one find/replace.)
What the trigger metric is
Same format → fixture_path mapping repeated in 3+ feature files. Below that threshold the duplication cost is lower than the indirection cost of a second YAML file plus the lint surface to validate it. Above it, the lookup pays for itself.
What it WOULD look like
L2 frontmatter gains an optional uses_fixtures field naming a YAML file under tests/acceptance/fixtures/lookups/:
---
doc-audience: human
status: validated
uses_fixtures: lookups/recording-formats.yaml
aliases:
primary:
- "{persona} uploads a {format} file titled \"{title}\""
---
The lookup file maps one column value to a bag of derived vars:
keyColumn: format
entries:
MP3:
FIXTURE_PATH: /fixtures/media/sample.mp3
FIXTURE_FILENAME: sample.mp3
FIXTURE_CONTENT_TYPE: audio/mpeg
WAV:
FIXTURE_PATH: /fixtures/media/sample.wav
FIXTURE_FILENAME: sample.wav
FIXTURE_CONTENT_TYPE: audio/wav
The skill would read uses_fixtures at L2-load time, and during Outline expansion would look up the matched row's keyColumn value, merging the table entry's keys into the params bag alongside the column-derived ones. Lint would gain rules for unknown-key, missing-file, malformed-table, and missing-uses_fixtures-when-required.
None of this is in the current plan. The current plan puts those same six fixture columns directly into the Examples block, where they're visible to spec readers, version-controlled in one place, and add zero machinery to the skill. The simplification is intentional and reversible.