BDD multi-persona: implementation plan

Multiple logged-in identities inside a single Gherkin scenario · Source pressure: ~8 @pending scenarios across team-membership, team-visibility, share-links · Drafted 2026-05-13

At a glance

~8
scenarios blocked today
1
VM per persona (rec.)
2–3
VMs in a typical multi-persona scenario
0
new orchestrator endpoints required
1
new field on RunPlanStep (persona)
Headline finding: the orchestrator already has the multi-session-per-run primitive we need (POST /v1/runsrunId; POST /v1/sessions {runId, persona} attaches a fully isolated VM to that run). The skill already drives it: one run = many scenarios, each its own session/VM. Multi-persona inside a single scenario is the obvious next step on the SAME primitive — not new machinery. We are adding one persona field per step in the RunPlan and a small per-scenario runner change that creates one session per named persona under the same runId. Verb routing is unchanged: the verb proxy still dispatches by :sid.
What we are NOT doing: we are not adding a “multi-context-one-VM” layer. We considered it (cheaper, faster) and rejected it — the scenarios in scope need CLI auth.json isolation AND filesystem isolation AND browser isolation, not just cookie-jar isolation. See §1 for the comparison.

Suspicion-first read of the codebase

The prompt asked me to check whether the orchestrator already has a multi-session model before designing new machinery. It does. Concretely, in this worktree:

  • tests/bdd/orchestrator/src/runs.tsPOST /v1/runs allocates a runId + on-disk artifact dir; RunRecord.sessionIds is a list. attachSessionToRun() is already exported and called from sessions.ts.
  • tests/bdd/orchestrator/src/sessions.tsPOST /v1/sessions already accepts an optional runId (zod schema line ~486: runId: z.string().min(1).startsWith("run_").optional()). It validates the run is in status=running, attaches the new session, and stamps artifacts under runs/<rid>/scenarios/<sid>/.
  • tests/bdd/runner/src/runner.ts — today's runner calls createSession({ persona, runId? }) exactly once per scenario (line ~223), threads sessionId into every dispatchStep call, and DELETEs at the end. The session is single-persona by virtue of being created once per scenario.
  • tests/bdd/runner/src/types.tsRunPlan.persona is at the plan level (one persona for the whole plan), NOT per step.
  • tests/bdd/runner/src/persona.ts — persona table is already keyed by lowercase first-name; Tina / Marcus / Priya / Gabe / Claude all defined. No code change needed to name a second persona; the gap is purely in dispatching to one.
  • tests/bdd/orchestrator/src/routing.ts — verb proxy is app.all('/v1/sessions/:sid/*'). Already addresses by sid. No protocol change required for multi-persona; we just need the right sid on each verb call.

So the rephrased question becomes: at which level of the stack do we lift the “one scenario = one session” assumption to “one scenario = N sessions, addressable by persona”? That's the actual work, and it is mostly in the runner and the bdd-exec skill, not the orchestrator.

Scenarios this unblocks (and which to implement alongside the framework PR)

Implementing this plan means flipping these specific scenarios from @pending to validated. They share the same shape: two or three named actors in one Gherkin scenario, with a step crossing personas. Each row below should run end-to-end against test.sageox.ai before the cluster is considered shipped.

Feature fileExact scenario namePersonas
team-management/team-membership.featureTina invites Marcus, an existing SageOx usertina + marcus
team-management/team-membership.featureTina invites Gabe, who is not yet a SageOx usertina + gabe (registers in his own VM)
team-management/team-visibility.featureOwner makes their team public (second Then: signed-in non-member can view Overview)tina + priya
team-management/team-visibility.featureNon-member can view Overview section of a public team (Outline row 1)tina + priya
team-management/team-visibility.featureNon-member can view Discussions section of a public team (Outline row 2)tina + priya
team-management/team-visibility.featureNon-member can view Sessions section of a public team (Outline row 3)tina + priya
team-management/team-visibility.featurePriya sees Access Required on Settings section of a public team (Outline row)tina + priya
team-management/team-visibility.featurePriya sees Access Required on Web recorder section of a public team (Outline row)tina + priya
sharing/share-links.featureSigned-in non-member of the team can resolve an authenticated sharetina + priya

Count: ~9 scenarios flip from @pending to validated.

What this list does not include: the anonymous-viewer share-link and anonymous-on-public-team scenarios. Those are a separate shape — see §6 for the anonymous-as-persona design and the additional scenarios that §6 unblocks in P2.

1. Session-vs-persona model

Three placement options were on the table. The prompt is right that this is the load-bearing decision; the rest of the plan flows from it.

OptionIsolationCostFit for scenarios in scopeVerdict
(a) Multiple personas IN ONE VM, multiple Stagehand BrowserContexts Browser only. Shared auth.json, shared ox CLI state, shared $HOME, shared filesystem. Cheap. One VM boot per scenario regardless of persona count. ~10s amortized. Marginal. Works for the team-visibility & share-links Priya cases (web-only personas). Fails the team-membership Marcus case the moment we want him to ox login in his own CLI state — and we do, because the “Marcus joins via invite link” rule eventually wants Marcus's ox daemon visible to other team members. reject
(b) Multiple personas across MULTIPLE microVMs linked by runId — one VM per persona Total. Each VM has its own auth.json, browser state, filesystem, ox daemon. ~10s VM boot per additional persona. 2-persona scenario = 2 VMs. Wall-time amortizes well (VMs spin up in parallel; only the slowest gates step 1). Excellent. Matches the existing primitive: POST /v1/runs already lets you attach N sessions under one run. The skill already does this across scenarios; doing it within a scenario is a natural extension. RECOMMENDED
(c) Per-persona context within one VM PLUS spawn extra VMs for CLI-heavy personas Hybrid — some personas isolated, others sharing. Slightly cheaper than (b) at small N. Two routing modes to maintain. Adds a classification step (“is this persona CLI-heavy?”) that authors will get wrong. The cost savings vs (b) at our N (typically 2, sometimes 3) are marginal. reject — complexity not earned by the workload.

Why (b) is the right pick

  • The primitive already exists. RunRecord.sessionIds is a list; attachSessionToRun is already wired; the verb proxy already routes by :sid. Picking (b) means writing zero new orchestrator code and reusing the run-finalize / artifact-layout / dashboard machinery.
  • Truth-of-isolation matches the truth-of-product. In production, Tina and Marcus have separate cookies because they live in separate browsers. (a) would test something more permissive than reality and could mask a real isolation bug. The walk-04 invitation flow, for instance, would silently pass if a stray cookie carried over — exactly the kind of false-green we're trying to avoid.
  • CLI personas are first-class. The ox daemon is a per-user process with its own auth state. We will absolutely have a scenario where “Marcus runs ox agent prime on the team that Tina created” — (a) cannot model two simultaneous ox daemons in one VM without contortions; (b) gets it for free.
  • Wall time is acceptable. See §7. The VM boot for the second persona overlaps with the “Given Tina has a team” setup steps in the first VM, so the effective marginal cost is closer to 3–5s than 10s for the second persona.

The three layers — and the one that stays implicit

There are three logical layers in play. Only two are first-class in the orchestrator; the third (scenario) is owned by the BDD skill + runner and is invisible at the orchestrator boundary. Being explicit about this asymmetry up front avoids the “there's a missing layer” question reading the rest of the plan.

LayerIdentifierCardinalityOwned byVisible at orchestrator API
Run runId 1 per /bdd-exec invocation orchestrator Yes — POST /v1/runs, POST /v1/runs/<rid>/finalize, artifact dir runs/<rid>/
Scenario scenario name (string label) 1..N per run BDD skill + runner (in-memory) No — the orchestrator sees only sessions tagged with the run; it has no concept of which sessions belong to the same Gherkin scenario
Session sid 1..M per scenario (today: M=1; after this plan: M = number of personas) orchestrator Yes — POST /v1/sessions, verb proxy :sid, artifact dir runs/<rid>/scenarios/<sid>/

The orchestrator's on-disk path runs/<rid>/scenarios/<sid>/ is a naming convention from a time when 1 session = 1 scenario. The directory is keyed by sid, not by scenario; after this plan, two sibling scenarios/<sid>/ directories may belong to the same Gherkin scenario. The runner and report renderer reconstruct that grouping from in-memory metadata it controls.

Why keep scenario implicit at the orchestrator API: making scenario first-class in the orchestrator would mean a new endpoint (POST /v1/runs/<rid>/scenarios), a new id type (scenarioId), a renested artifact tree (runs/<rid>/scenarios/<scid>/sessions/<sid>/), and a renderer rewrite. That's real new orchestrator code — not the “zero new orchestrator code” line in the headline. The runner-side grouping is sufficient: every session already has a stable label (plan.feature + plan.scenario) the renderer reads to reconstruct the grouping at finalize time.

The report makes scenario explicit, even though the API doesn't

The HTML report rendered at runs/<rid>/report/index.html is the load-bearing surface for humans + replay tools reading run state after the fact. It MUST surface scenario as a visible grouping layer, even though the orchestrator's storage doesn't model it. Today's renderer treats each session as a flat row under the run; the change is to group by scenario label first, then list each session's transcript inside that group, with per-step persona attribution.

Concretely the report changes shape from this:

Feature: features/team-membership
  Session sid_mp3xyz — passed (5/5 steps)
    Given Marcus is signed up for a SageOx account     ✓
    When  he opens the invite link                     ✓
    Then  he should be a member of "Acme Engineering"  ✓

  Session sid_mp3abc — passed (4/4 steps)
    Given Tina is signed up for a SageOx account       ✓
    When  she invites Marcus to "Acme Engineering"     ✓

to this:

Feature: features/team-membership
  Scenario: Tina invites Marcus, an existing SageOx user
    Personas in this scenario: tina (sid_mp3abc), marcus (sid_mp3xyz)

    [tina]    Given Tina is signed up for a SageOx account     ✓
    [tina]    And she has a team called "Acme Engineering"     ✓
    [tina]    When Tina invites Marcus to "Acme Engineering"   ✓
    [marcus]  Given Marcus is signed up for a SageOx account   ✓
    [marcus]  When Marcus opens the invite link                ✓
    [marcus]  Then Marcus should be a member of "Acme..."      ✓

What changes in the renderer:

  • Group rollup: read each session's run-report.json (the runner already writes these) and group by the scenario field. Today the renderer iterates sessions; after this change it iterates scenarios, then sessions within each scenario.
  • Per-step persona prefix: each step row prefixes the step text with [persona] (the persona key from RunPlanStep.persona, falling back to plan.persona for unannotated steps). On single-persona scenarios the prefix is omitted to avoid visual noise.
  • Step interleaving: when a multi-persona scenario's steps run partly on one session and partly on another, the report renders the canonical Gherkin order (the order the steps appeared in the .feature), not the per-session order. The runner already preserves this in RunPlanStep.idx; the renderer just sorts by it across all sessions in the scenario.
  • Header line: “Personas in this scenario: tina (sid_...), marcus (sid_...)” gives the reader the mapping from persona name to backing session id so they can drill into the per-session artifacts (PNGs, network logs) when they need to.
  • JSON sidecar: alongside report/index.html, the renderer writes report/scenarios.json with the explicit grouping ([{ scenario, personas: [{name, sid}], steps: [...] }]). This is the artifact a replay tool or external dashboard reads to reconstruct the model the orchestrator API doesn't expose.

So: the orchestrator stores a flat session list, but the report exposes the three-layer truth (run → scenario → session). The implicit-at-API / explicit-in-report split is the contract.

Restated: a multi-persona scenario is N sessions under one runId, where N = number of distinct named actors in that scenario. The orchestrator sees a flat list of sessions on the run; the runner knows which of those sessions form one Gherkin scenario; the report makes that scenario grouping explicit for humans and downstream tools.

2. Verb routing model

Once we have multiple sessions, each step needs to be addressable to ONE of them. Three options:

OptionWhere addressability livesAuthor burdenVerdict
(a) Each RunPlanStep carries an explicit persona field; runner maps persona → sid Plan-level, explicit, declarative. Skill emits the persona on each step. The skill ALREADY resolves “which persona is this step's actor?” (it has to, to substitute $NAME/$EMAIL) — so this is exposing existing knowledge, not asking for new authoring. RECOMMENDED
(b) Plan splits into multiple sub-plans (one per persona) tied by runId Plan-level, but the cross-persona ordering and shared-vars are now an orchestration problem. Heavy. Need to invent “sub-plan barriers” or re-order steps into per-persona phases, losing the natural Gherkin reading order. Authors of L2 contracts that export vars (e.g. $INVITE_URL) would have to think about which sub-plan exports cross to which other sub-plan. reject — over-isolates; punishes legibility.
(c) An “active persona” pointer that scenario steps flip via a special verb (switch-to-persona) Imperative, hidden state. Cheap to author in the trivial case. Bad in the “step N+1 reads the wrong persona because someone forgot the switch verb at step N” case. Imperative state coupled to scenario order is exactly the kind of footgun BDD is meant to suppress. reject — couples to ordering, hides intent.

Concrete shape of (a)

Today's RunPlanStep (in tests/bdd/runner/src/types.ts, line ~94) carries:

{ phase, stepText, spec, source?, params? }

The change is one optional field, with the plan-level persona still acting as the default actor (back-compat — single-persona plans don't have to set it per step):

{ phase, stepText, spec, source?, params?,
  persona?: string  // optional; defaults to plan.persona }

The runner maintains a small Map<personaName, sid> populated as sessions are created. dispatchStep consults the map to pick the :sid for the verb proxy URL. That's the entire routing change. The verb proxy itself does not change.

The plan-level persona stays put. It defines the “primary” persona of the scenario — the one whose sharedVars are seeded by the runner (the buildSharedVars(persona.defaults) call at runner.ts line ~123), and the one whose name appears in the report header. For single-persona scenarios, every step's persona is omitted (or equals plan.persona) and behavior is unchanged. For multi-persona scenarios, plan.persona = whichever persona has the most steps, or the first speaker.

3. Plan emit changes (bdd-exec skill)

The skill already resolves an actor per step. The prompt's question is: how does it bind each step's actor when the scenario has multiple named actors?

Today, per tests/bdd/skills/bdd-exec/SKILL.md phase 3, the skill's rule is:

The scenario's actor comes from the most recent named persona in scope. Subsequent pronouns ("he"/"she"/"they") refer to that persona until another is named.

That rule already handles multi-persona scenarios correctly — it's per-step, not per-scenario. The blocker is that today the skill collapses the per-step actor down to plan.persona (single field). With option 2(a), the per-step actor is preserved straight onto RunPlanStep.persona.

Algorithm (delta from today)

  1. Scan the scenario's steps in order. Maintain currentActor: string | null.
  2. For each step, before substitution: parse the step text. If a known persona name is mentioned, set currentActor to its lowercase first name. If a pronoun is mentioned, currentActor stays. (This is the existing rule.)
  3. Stamp the step's persona field with currentActor.
  4. Collect the SET of personas across all steps. That's the scenario's actor list. The first one to speak becomes plan.persona (the “primary”); the rest are secondaries.
  5. For $NAME / $EMAIL / $PASSWORD substitution in each step's spec: use THAT step's actor's persona defaults, not the plan-level primary's. (This is a substitution-loop change in the skill; today it resolves once per plan, needs to resolve once per step.)

Per-persona uniquified email

The runner currently mints one fresh email per RUN by appending <persona>-<epoch>-<rand>@example.com. With multiple personas in a scenario, we need ONE fresh email per persona, not one per scenario. The runner's buildSharedVars already keys by persona; we extend it to a map: vars.byPersona['tina'] = { NAME, EMAIL, PASSWORD }, and step dispatch picks the right block by the step's actor.

Edge case: alias capture across personas

If Tina's step exports $INVITE_URL=... and Marcus's step references $INVITE_URL, the export must be visible to Marcus's step. The L2-contract export bag (today: StepResult.exportedVars → merged into sharedVars) is already scenario-scoped, not session-scoped. Keep it scenario-scoped. This is the natural place to leak the invite URL from Tina's flow into Marcus's flow — it models the real world (the URL exists in the email; Marcus copies it from there) without inventing new machinery.

4. Orchestrator API surface

No new endpoints. The existing POST /v1/sessions + POST /v1/runs primitive covers the case. Concretely:

CallTodayMulti-persona
POST /v1/runs once per skill invocation, returns runId unchanged
POST /v1/sessions {runId, persona, feature} once per scenario (one persona) once per persona per scenario. The runner makes 2 calls back-to-back with the same runId, different persona.
POST /v1/sessions/:sid/<verb> routed by sid — one sid per scenario routed by sid — runner picks the right sid per step from its personaName → sid map
DELETE /v1/sessions/:sid once per scenario, on teardown N times per scenario, fired in parallel (Promise.allSettled). Idempotent on the orchestrator side.
POST /v1/runs/:rid/finalize once at end of skill invocation unchanged. The pre-finalize check already iterates record.sessionIds and waits for all of them to be terminal — works as-is.

Cosmetic-only changes I can imagine wanting, but rejecting

  • A GET /v1/runs/:rid/sessions?persona=marcus filter — could be useful if the dashboard wants to render scenario-grouped session lists. But the dashboard already has the runId → sessions linkage; persona filtering can be a client-side filter on the existing list. Reject.
  • A scenario_id dimension on RunRecord.sessionIds so we can answer “which sessions belonged to which scenario.” This IS something we'll want for the dashboard eventually, but the current data model lets the runner write a scenario.json next to each session's artifacts that lists its sibling sessions — cheaper, and doesn't change the orchestrator contract. Defer.

Daemon protocol changes

None. Each daemon is still one-VM, one-persona, one-Stagehand-context. The daemon never sees its sibling personas; siblings live in sibling VMs. This is the single biggest reason to pick model (b) over (a) — the daemon's invariant (“I am one user”) doesn't have to change.

What “zero new orchestrator code” actually means — concrete diff sizes

The "zero new orchestrator code" line in the headline is true for the orchestrator's API surface. There IS one orchestrator-package change — the HTML renderer (report-html.ts) that builds runs/<rid>/report/index.html at finalize time. The renderer lives inside the orchestrator package but isn't part of the orchestrator's public HTTP contract; it's a pure rendering job that reads each session's persisted run-report.json. The scenario-grouping work from §1 is implemented here.

FileChangeDiff sizeLayer
tests/bdd/runner/src/types.ts Add persona?: string to RunPlanStep schema (Zod optional). Backward-compatible. ~3 lines runner
tests/bdd/runner/src/runner.ts Replace the single createSession() at line ~223 with a Promise.all fan-out over distinct personas in plan.steps[].persona, storing results in a Map<persona, sid>. In the step loop (~line 352), replace the single sessionId with sidByPersona.get(step.persona ?? plan.persona). Teardown becomes Promise.allSettled over all sids. ~40 lines runner
tests/bdd/orchestrator/src/report-html.ts Group session reports by plan.feature + plan.scenario label. Render the “Personas in this scenario” header. Emit per-step [persona] prefix when a scenario has > 1 persona. Sort steps across sessions by canonical Gherkin index (step.idx) for cross-session interleaving display. Write the scenarios.json sidecar described in §1. ~80–120 lines orchestrator (renderer only, not API)
tests/bdd/skills/bdd-exec/SKILL.md Phase 3 doc: per-step actor binding. Phase 6 doc: emit RunPlanStep.persona when actor differs from plan.persona. Phase 7 doc: clarify that the runner now creates N sessions per scenario but the skill still talks to one runner per scenario. ~30 doc lines skill
Total ~150–200 LOC of code + doc, no new endpoints, no new id types, no daemon-protocol fields

Concretely verified against the worktree at commit time:

  • POST /v1/sessions Zod schema (sessions.ts:471, 486) already takes {persona, runId} — the orchestrator never assumed one session per scenario.
  • RunRecord.sessionIds is already an array (runs.ts) and is mutated via attachSessionToRun() — already supports N.
  • POST /v1/runs/:rid/finalize's pre-finalize check (runs.ts) iterates the full sessionIds list and waits for all to be terminal — works for N as-is.
  • Verb proxy mount (routing.ts) is app.all('/v1/sessions/:sid/*') — routes by sid; no scenario or persona awareness needed at the routing layer.
  • Today's runner (runner.ts:223) already passes {persona, runId} when creating its single session; the change is “do this N times” not “rewrite the call.”
  • Today's dispatchStep({sessionId, step, sharedVars}) (runner.ts:352) already takes sessionId as an arg; the change is “pick the right one” not “rewrite the dispatcher.”

5. Persona lifecycle within a run

The prompt asks: when does the second persona get its account? For Marcus-as-invitee, registration has to happen IN HIS CONTEXT, not Tina's. The lifecycle order in a multi-persona scenario is:

  1. Plan emit (skill). Skill identifies actor set: {tina, marcus}. Marks each step's persona field.
  2. Run create (runner). Runner POSTs /v1/runs once for the skill invocation. (Today this happens in the skill, not the runner — doesn't change.)
  3. Pre-scenario session bootstrap (runner). Before the first step of the scenario, the runner inspects the SET of personas in plan.steps[].persona and POSTs /v1/sessions {runId, persona, feature} once per distinct persona. These calls fan out concurrently (Promise.all) so VM boots overlap. The runner populates its Map<personaName, sid> as each promise resolves.
  4. Given-phase setup. “Given Tina has registered for an account” runs in Tina's VM via its fast-path BA. “Given Marcus has registered for an account” runs in Marcus's VM via the same BA. Each Given resolves its preconditions in the correct VM; the fast paths don't care which VM they run in — they're just curl calls scoped to a logged-out browser.
    Note on the Marcus-as-new-user variant: when the scenario is “Tina invites Marcus, brand-new SageOx user,” we deliberately skip the “Given Marcus has registered” precondition. The When-phase (Marcus opens the invite link → clicks register → fills the form) is what creates his account, IN HIS VM. The runner doesn't have to know this is special; it just dispatches each step to the right sid.
  5. When-phase action. “When Tina sends an invite to Marcus’s email address” runs in Tina's VM, exports $INVITE_URL into scenario-scoped vars. “When Marcus opens the invite link” reads $INVITE_URL from scenario vars and dispatches to Marcus's VM.
  6. Then-phase verification. Verifications run in whichever VM the assertion is about — “Then Marcus should be a member” runs in Marcus's VM (his dashboard now shows the team); “Then Tina should see Marcus in the member list” runs in Tina's VM (her admin view).
  7. Teardown. Runner fires DELETE /v1/sessions/:sid in parallel for every sid in the map. All errors are captured into Promise.allSettled; one failed delete doesn't block the others.

Ordering invariants

  • Steps run sequentially in declared Gherkin order, even across sessions. This is the load-bearing invariant. The runner's existing per-scenario loop is “await step K, then dispatch step K+1.” That stays unchanged — the only thing that changes is WHICH session each step targets. Step K in Tina's VM fully completes (HTTP response, exports written to the scenario vars bag, assertions evaluated) BEFORE step K+1 dispatches, regardless of whether K+1 is in Tina's, Marcus's, or a third VM. There is no Promise.all across steps. There is no parallel step pipeline. Cross-session does NOT mean cross-step concurrency.
  • Why sequential, not parallel. Gherkin scenarios are written as a chain of cause → effect: “When Tina invites Marcus / And Marcus opens the invite link” only makes sense if Tina's invite has actually been sent and the URL has been exported before Marcus tries to open it. Running the two in parallel would race on (a) the scenario-scoped vars bag, (b) the SUT's database state, and (c) any verb that reads what an earlier verb wrote. A second-persona VM sitting idle while the first-persona VM finishes a step is an accepted cost for that ordering guarantee.
  • VM boot completes before its first step. The runner awaits all session-create promises before starting step dispatch. A slow boot delays scenario start by max(N boot times), not sum. In practice the slowest boot is ~10s and the rest overlap. VM boot is the ONLY thing that parallelizes; step execution does not.
  • Scenario-scoped vars bag is shared. Vars exported in step K are visible to step K+1 regardless of persona. This is the existing behavior, just acknowledged at the new boundary — and it depends on the sequential invariant above. A step's exports aren't visible to anyone until after the step's response is processed; running concurrent steps would either need a different vars model or accept lost-update races.
  • No cross-VM verb dispatch. A step is dispatched to exactly one sid. We do not have a primitive for “assert simultaneously in two VMs”; if a scenario wants that it has to be two consecutive Then steps with different actors, each running sequentially.

What the runner loop actually looks like (pseudocode)

// Multi-persona delta from today's runner loop:
//   1. Session bootstrap is per-persona (parallel).
//   2. Step dispatch picks sid by step.persona (sequential, unchanged).
const sidByPersona = await bootstrapSessions(plan);  // Promise.all over distinct personas
for (const step of plan.steps) {                      // sequential
  const sid = sidByPersona.get(step.persona ?? plan.persona);
  const result = await dispatchStep({sid, step, sharedVars});  // awaited!
  applyExports(sharedVars, result.exports);                    // visible to step K+1
  if (result.status === 'fail') break;                         // existing fail-fast
}
await Promise.allSettled([...sidByPersona.values()].map(deleteSession));

The for-await is the sequential guarantee. Replacing it with Promise.all over steps would break the model. The work to add is the per-persona sidByPersona map and the step.persona lookup — nothing else.

6. Anonymous context

The prompt is right that PII-boundaries already routes around this question by using Node-side fetch from outside any VM. But two scenario shapes need a real anonymous browser context:

  • share-links.feature — “anonymous viewer opens the public share link → lands on the resource” AND “anonymous viewer opens the authenticated-audience share link → redirected to login.” The redirect-to-login test specifically wants a logged-out browser, not just an unauthenticated curl.
  • team-visibility.feature — “anonymous on a public team can browse public sections” (and its sibling, “anonymous on a private team is bounced to login”).

Decision: anonymous is a persona

Add anon to the persona table in tests/bdd/runner/src/persona.ts. Its defaults block is empty (no NAME, no EMAIL, no PASSWORD). The skill recognizes “anonymous,” “anon,” “an anonymous viewer,” “a logged-out user” as binding to this persona.

Why model it as a persona rather than a distinct shape:

  • The verb routing in §2 already keys by persona. Treating anon as “a persona with empty defaults” means the routing logic doesn't have to special-case anonymity — it just dispatches to a sid that happens to belong to a VM where Stagehand never ran a login flow.
  • The session bootstrap in §5 already creates one VM per distinct persona. The anon VM is identical to any other VM except that no ox login / web-login Given step runs in it.
  • It composes naturally: {tina, priya, anon} in one scenario is the same shape as {tina, marcus, priya} — three VMs, three sids.

The one wrinkle: registration BA. When the skill sees “Given anonymous is a fresh visitor” it MUST NOT dispatch the registration fast path — the whole point is the browser has no cookies. The skill's BA selection already gates on the Given's verb phrasing; we add “fresh visitor / logged-out” as a no-op precondition (the VM is logged-out by default at boot, so “nothing” is the right BA).

7. Resource budget

Per-scenario cost

Persona countVMsBoot wall time (cold)Boot wall time (parallel)LLM costNotes
1 (today) 1 ~10s ~10s baseline X Unchanged.
2 2 ~20s sum ~11s — second VM boots in parallel with first VM's Given setup ~1.5–1.8X The second VM's Stagehand startup cost dominates; LLM cost grows because Marcus's flow also exercises the model.
3 3 ~30s sum ~12s ~2X–2.5X Rare. Mostly hypothetical pii-boundaries-style scenarios.

Suite-level cost

Today: ~190 scenarios in the repo, of which ~30 are validated and the rest @pending. Of the 30 unblocked-after-this-work scenarios, ~8 are multi-persona (the ones listed in §Blocked). The other ~22 stay single-persona.

If we assume an average of 2 personas per multi-persona scenario and 1 persona elsewhere, the suite-level VM count grows from ~190 VMs to ~190 + 8 = ~198 VMs at full coverage — a ~4% increase. Not “every scenario doubles.” The fear in the prompt is unfounded for the workload we're targeting; multi-persona is a minority of scenarios, and most stay single-persona.

Concurrency caps

The orchestrator's SessionPool already caps total concurrent sessions (poolEnv.cap — defaults to some N tunable via env). With multi-persona, a single scenario consumes 2–3 slots from the pool instead of 1. Verify the pool cap covers max(active scenarios) × max(personas per scenario), not just max(active scenarios). If the cap is set such that 8 scenarios run concurrently and each consumes up to 3 slots, we need cap ≥ 24, not 8. Document this in the skill's run-orchestration contract.

Stretch concern: cost growth if multi-persona spreads

If, over time, scenario authors discover that they like the multi-persona pattern and start using it everywhere (“every scenario should have an ‘adversary’ second user”), suite cost grows linearly. This is a discipline problem, not a mechanism problem — the BDD smell sweep already flags “tests with no real test boundary” as anti-patterns; we add “multi-persona where one persona suffices” to the smell catalog.

8. Worked example: “Tina invites Marcus, existing SageOx user”

The scenario

Scenario: Existing user joins a team via invite link
  Given Tina has registered for an account
  And Tina has created a team named "Sage Engineering"
  And Marcus has registered for an account
  When Tina sends an invite to Marcus's email
  And Marcus opens the invite link
  And Marcus accepts the invitation
  Then Marcus should be a member of "Sage Engineering"
  And Tina should see Marcus in the member list

BEFORE (today)

The skill emits a RunPlan with plan.persona = "tina". The runner creates one session with persona=tina. Every step dispatches to that one sid. Result:

  • Step 3 (“Marcus has registered”): registration BA runs in Tina's VM. It tries to register Marcus's email, but Tina's browser is already logged in as Tina — the BA's fast path either errors (logged-in user can't visit /register), or worse, silently succeeds with weird state.
  • Step 5 (“Marcus opens the invite link”): the verb “open-invite-link” dispatches to Tina's VM. Tina's browser navigates to the invite URL. The app sees Tina, not Marcus — the invite is consumed by the wrong user, or the app refuses because Tina is the inviter.
  • Step 7 (“Marcus should be a member”): verification dispatches to Tina's VM. Either the verification is shaped wrong (Tina IS already a member, vacuously passes — classic false green) or it asks “is Marcus in the member list,” which Tina's VM can see (correct answer, accidentally), and we get a passing test for the wrong reason.

Net: the scenario is impossible to dispatch correctly. It currently sits at @pending for exactly this reason.

AFTER (with this plan)

RunPlan emitted by the skill

{
  feature: "team-membership.feature",
  scenario: "Existing user joins a team via invite link",
  persona: "tina",                  // primary; first speaker
  runId: "run_lzy3xq4-8a1f",
  steps: [
    { phase: "given", stepText: "Tina has registered for an account",
      persona: "tina",   spec: {...registration BA...} },
    { phase: "given", stepText: "Tina has created a team named \"Sage Engineering\"",
      persona: "tina",   spec: {...team-creation BA...} },
    { phase: "given", stepText: "Marcus has registered for an account",
      persona: "marcus", spec: {...registration BA...} },
    { phase: "when",  stepText: "Tina sends an invite to Marcus's email",
      persona: "tina",   spec: {...send-invite BA; exports $INVITE_URL...} },
    { phase: "when",  stepText: "Marcus opens the invite link",
      persona: "marcus", spec: {...open-invite-link BA; reads $INVITE_URL...} },
    { phase: "when",  stepText: "Marcus accepts the invitation",
      persona: "marcus", spec: {...accept-invitation BA...} },
    { phase: "then",  stepText: "Marcus should be a member of \"Sage Engineering\"",
      persona: "marcus", spec: {...verify-membership verification...} },
    { phase: "then",  stepText: "Tina should see Marcus in the member list",
      persona: "tina",   spec: {...verify-member-visible verification...} },
  ]
}

Runner → orchestrator interaction

// 1. Skill creates the run (existing).
POST /v1/runs { caller: "bdd-exec" }
→ { runId: "run_lzy3xq4-8a1f" }

// 2. Runner inspects plan.steps[].persona, finds {tina, marcus}.
//    Bootstraps both sessions in parallel.
Promise.all([
  POST /v1/sessions { runId, persona: "tina",   feature: "team-membership.feature" },
  POST /v1/sessions { runId, persona: "marcus", feature: "team-membership.feature" },
])
→ { sessionId: "ses_abc1" }   // tina
→ { sessionId: "ses_def2" }   // marcus

// 3. Runner builds the persona → sid map.
sidByPersona = { tina: "ses_abc1", marcus: "ses_def2" }

// 4. Dispatch loop. For each step, pick the sid:
POST /v1/sessions/ses_abc1/onboarding/auth/register      // step 1: tina
POST /v1/sessions/ses_abc1/team/team/create-team          // step 2: tina
POST /v1/sessions/ses_def2/onboarding/auth/register      // step 3: marcus  ← in HIS VM
POST /v1/sessions/ses_abc1/team/membership/send-invite   // step 4: tina; exports $INVITE_URL
POST /v1/sessions/ses_def2/team/membership/open-invite   // step 5: marcus; reads $INVITE_URL
POST /v1/sessions/ses_def2/team/membership/accept-invite // step 6: marcus
POST /v1/sessions/ses_def2/_capture-state                // step 7: marcus assertion
POST /v1/sessions/ses_abc1/_capture-state                // step 8: tina assertion

// 5. Teardown in parallel.
Promise.allSettled([
  DELETE /v1/sessions/ses_abc1,
  DELETE /v1/sessions/ses_def2,
])

// 6. Skill finalizes the run (existing).
POST /v1/runs/run_lzy3xq4-8a1f/finalize

Total wall time: ~12s VM boot (parallel) + the scenario's step time + ~2s teardown. Compared to a single-persona scenario: +~2s for the extra teardown, +~1s of marginal VM boot overhead. The scenario does ~1.5–1.8× the LLM work because Marcus's flow exercises Stagehand too.

What did NOT change

  • Verb proxy is unchanged. Still /v1/sessions/:sid/*, still routes by sid.
  • Daemon is unchanged. Each daemon thinks it's the only one.
  • Run finalize is unchanged — findNonTerminalSessions iterates record.sessionIds; that loop doesn't care that two of them came from one scenario.
  • Dashboard renders both sessions as siblings under the run, exactly as it does today across scenarios.

9. Phasing

  1. P0 — minimum-viable, single scenario unblocked

    Target: “Tina invites Marcus, existing SageOx user” flips from @pending to validated.

    Scope:

    • Add optional persona field to RunPlanStepSchema (back-compat: omitted = default to plan.persona).
    • Add sidByPersona map to runner.ts; replace the single createSession call with a Promise.all over the distinct persona set.
    • Replace sessionId argument to dispatchStep with the per-step sid lookup.
    • Replace sharedVars with a per-persona vars block; substitution loop picks the right block by the step's actor.
    • Update teardown to Promise.allSettled over all sids.
    • Update the bdd-exec skill: per-step actor resolution (already done in prose, now needs to flow into persona on each emitted step).
    • Wire the team-membership invitation BAs (send-invite, open-invite, accept-invite) end-to-end. Some may already exist for the single-persona walk-04 case — if so, just refactor them to be VM-agnostic (they don't care which VM they run in).

    Excludes: anonymous persona; team-visibility Priya scenarios; share-links Priya; any orchestrator API changes.

    Includes (orchestrator-package work): the report-html.ts renderer change for scenario grouping + per-step persona prefix + scenarios.json sidecar. NOT an API change; lives inside the orchestrator package but doesn't touch the HTTP contract. See §4 diff table for the file-by-file breakdown.

    Acceptance: the one named scenario passes. The smoke script still passes (back-compat for single-persona plans). The HTML report visibly groups sessions under the scenario heading with persona attribution on each step.

    ~1 PR, ~600–1000 LOC net change concentrated in runner.ts (~40 LOC), types.ts (~3), report-html.ts (~80–120), skill doc (~30), plus the three invitation BAs (~150) and tests (~200–500). The framework primitives are ~150–200 LOC; the rest is BA wiring + tests. Zero net change to the orchestrator's HTTP API.

  2. P1 — the second multi-persona scenario shape (signed-in stranger)

    Target: team-visibility Priya outline (~5 scenarios) and share-links Priya scenario flip to validated.

    Scope:

    • No runner / orchestrator changes — P0's surface is sufficient.
    • Wire the team-visibility verifications for the public-team-as-stranger view.
    • Wire the share-link resolution BA for the “different signed-in user” case.

    Acceptance: the ~5 Priya scenarios pass.

  3. P2 — anonymous persona

    Target: share-links anonymous viewer + team-visibility anonymous viewer scenarios.

    Scope:

    • Add anon to the persona table.
    • Skill: recognize anonymous-keying phrases. Suppress the registration BA for anon-keyed Givens.
    • Document “anonymous is a persona with empty defaults” in tests/acceptance/personas.md.
  4. P3 — the “brand-new SageOx user” Marcus variant

    Target: the second team-membership scenario (Marcus registers via the invite-link flow itself).

    Scope: entirely in the skill (the registration BA already exists in single-persona; we're just changing which VM it dispatches to and lifting the “has registered” precondition for that scenario).

  5. P4 (deferred) — observability polish

    Live-status dashboard that consumes the orchestrator's session-event stream and groups by scenario in real time (today's polish is finalize-time only, baked into report-html.ts in P0). SessionPool cap audit + documentation for the multi-persona case. Persona-aware artifact retention rules. None of this blocks any scenario; raise it once the suite has > 20 multi-persona scenarios and the observability gap is felt.

10. Risks

RiskLikelihoodImpactMitigation
Cookie scope leakage between VMs. Very low Would silently mask isolation bugs. Each VM is a separate shuru microVM — cookies don't cross. This is the whole point of picking model (b). Confirmed by the existing parallel-sessions smoke (scripts/smoke-parallel-sessions.ts) which already runs N concurrent sessions in N VMs.
Session-creation race — one of N personas fails to boot. Low Scenario fails with confusing “why is marcus undefined?” error. Promise.all rejects on first failure. Runner catches and emits a synthetic “create session [marcus]” fail row, tears down any sessions that DID boot, and blocks the rest of the steps. Same shape as today's single-persona create-session failure (runner.ts line ~234).
Test data isolation across personas (collisions). Medium Two personas with the same email address → database collision; second registration fails. Per-persona email uniquification (§3): each persona gets its own EMAIL = <name>-<run epoch>-<rand>@example.com, scoped to the run not the scenario. Two sibling scenarios in the same run for the same persona get the same email — intentional, so a Given’s “has registered” precondition can short-circuit on the second scenario if we want it to.
Database state visible across personas masks a leak. Real concern Scenario expects “Marcus sees the team because Tina invited him” — this is correct! — but the same shape would also pass if there were an authz bug letting Marcus see ANY team Tina created. Visibility is intended; over-visibility is a bug. Pair every multi-persona “X should see Y's thing” scenario with a NEGATIVE scenario where the persona should NOT see something. The team-visibility scenarios already do this (member can settings, non-member cannot). Add a doc-level checklist: any new multi-persona scenario MUST be accompanied by a negative scenario showing the same persona is denied something they shouldn't see.
Vars-bag exports cross persona boundary unintentionally. Low Scenario exports a sensitive token from VM 1 and accidentally re-uses it in VM 2 where it shouldn't. The vars bag is scenario-scoped by design (so $INVITE_URL CAN cross). Authors who want VM-scoped vars don't have a way to express that today — and shouldn't, because L2 exports are the documented contract. If a future scenario needs “this var must not leak,” that's a constraint to be enforced by the verification (the negative scenario), not the var system.
SessionPool cap starves a multi-persona scenario. Medium Mid-scenario, the second POST /v1/sessions hangs waiting for a slot; scenario times out. Bootstrap all sessions BEFORE the first step. If the pool can't satisfy all N at once, the scenario fails fast at startup with a clear error, not partway through. Document the cap math (§7) in the skill and in tests/bdd/orchestrator/AGENTS.md.
Run finalize hangs because one persona's session didn't terminate. Low Finalize times out; run shows as stuck. Existing behavior: findNonTerminalSessions returns a 409 with the offending sids. Same shape as today, just with a list of sibling-persona sids in the response. No change needed.
Author confusion: which persona does “they” refer to? Medium Step dispatched to the wrong VM → misleading test failure. The skill's pronoun-resolution rule (most-recent named persona) is already documented. Add lint: if a scenario has >1 named persona and uses an ambiguous pronoun (“they” with multiple recent referents), emit a warning. Don't fail the scenario — the rule is unambiguous, just hard for humans to track.

11. What this is NOT

  • Not a runtime “switch user” primitive. Personas are scenario-static. The set of personas is fixed at plan emit; we don't have switch-to-persona verbs.
  • Not a multi-tenant test framework. Each VM is one user. We are not modeling “two browser tabs in one VM, each logged in as a different user” — that's a real-world thing humans do, but it's not a product surface we test.
  • Not parallelization within a scenario. Steps run sequentially, even if step N targets Tina's VM and step N+1 targets Marcus's. We are not introducing simultaneous-action semantics (“Tina and Marcus both click X at the same time”) — those don't appear in the blocked-scenario list.
  • Not a new orchestrator endpoint, run model, or daemon protocol. The runId / sessionId primitives we already have are sufficient. Every change in this plan is in the runner or the bdd-exec skill.
  • Not in scope: cross-persona transcript/recording flows. Walk-7-style TUI-session scenarios that record AI-coworker conversations involving multiple users are blocked on the TUI driver maturing (separate track). Multi-persona is a precondition for some of them but does not unblock them on its own.
  • Not in scope: per-persona cost attribution in reports. Today's RunSummary sums LLM totals across all sessions in a run. Splitting the rollup by persona is a polish item, not a correctness gate. Deferred to P4.