Yoke documentation
Case attachment — which subject a QA case answers for
A qa_requirements row attaches to exactly one subject: an item, an epic task, or a deployment run (optionally narrowed to one stage inside that run, and one member item inside that stage). The polymorphic constraint enforcing that shape lives in qa-platform.md; this page covers who may author each shape, and what the shared reads return for it.
Authoring
Item and deployment-run cases are the same registered write, qa.requirement.add, distinguished by the target it names.
yoke qa requirement add --item PREFIX-N \
--method-id browser-check --qa-phase verification \
--workflow-transition reviewing-implementation \
--instructions "..." --expected-outcome "..." --method-config '{...}'
An item case is item-claim-gated: the calling session must hold the item's active work claim. --workflow-transition is required and names a stage in the item's pinned workflow. verification binds to a stage that carries or precedes a qa_verification gate (Dash review: reviewing-implementation). post_deploy and manual_acceptance bind to the pinned release wait or done — authoring refuses those phases on the review transition and names --workflow-transition release (or --deployment-run) as the recovery. A row already recorded against a pre-release stage rebinds in place rather than being replaced: yoke qa requirement update --requirement-id N --field workflow_transition_id --value release revalidates the new binding exactly as a fresh attachment would, so a rebind can only land where a create could. Until it does, no deployment run can admit the row, and the item's done transition keeps blocking on it. Do not guess the phase from a URL or environment name.
When item QA needs another item's shipped fix, record the wait on the existing dependency edge before retrying:
yoke items dependency add DEPENDENT BLOCKER operator --gate-point closure \
--satisfaction status:done --rationale "QA requires the blocker's shipped fix"
Read yoke items dependency add --help for satisfaction and direction. Use fact:deployed:<environment-name> when QA needs a registered environment before the blocker reaches done. Composition skips release items with an open blocking edge to an unshipped item and names the blocker in its receipt; the first release after the blocker ships enrolls the waiting item automatically. A blocker a live or settling release still holds has not shipped: the receipt names that run, and the first release after it settles enrolls the item. Coordination-only and satisfied edges do not delay composition.
Removing a release member also aborts its live run/member QA executions and releases their queued host turns and leases in the removal transaction. Standing item plans and passing evidence remain. A holder cleaning residue from an older removal can use yoke qa plan abort --deployment-run-id RUN --execution-id ID --reason TEXT --project P; read yoke qa plan abort --help first. The audited removal preserves abort authority under the member's item claim and execution ownership; it does not authorize continuing QA for a removed member.
yoke qa requirement add --deployment-run run-YYYYMMDD-NNN \
--method-id browser-inspection --qa-phase post_deploy \
[--deployment-stage STAGE [--deployment-member-item PREFIX-N]] \
--instructions "..." --expected-outcome "..." --method-config '{...}'
A run case is authorized by the run's own project scope — the qa_subject claim policy the rest of QA already writes under — and carries no workflow transition, because its delivery run owns that context. It must name a method (--method-id) and a bindable target: the frozen QA stage (so run/stage authority can stamp execution_target_json / execution_target_digest) or --target-env for a registered environment of the run's project.
The command refuses, by name and with the recovery:
- a run that has already succeeded, failed, or been cancelled — its evidence
is closed, and a case added afterwards would read as proof of something nobody ran;
- a stage the run's pinned flow does not declare, naming the stages it does;
- a member the run does not carry, naming the ones it does — run composition
is frozen at start and is not widened by attaching a case;
- a member named without a stage, which storage models as a check at a
stage and the subject constraint rejects half-named;
- a case with no bindable target — name
--deployment-stageafter the QA
stage's source receipt is ready, or --target-env.
--target-envthat disagrees with the QA stage's declared persistent
environment — even when that stage's source receipt is not ready yet. Named-env is not a substitute for a declared stage destination.
A hand-authored run/stage/member case carries the same execution-target fields plan materialization writes, so it is executable on that frozen destination. Existing unbound rows recover on yoke qa case run --requirement-id N — do not add the member to the run and do not attach a plan. When the flow lists no cases, those direct member cases are the selection; materialize does not copy them into a plan. A known run-attached method case still missing its target refuses freeze_run_composition (run start → executing) with yoke qa requirement update --requirement-id N --field target_env --value <environment>.
Epic-task attachment remains operator-debug only, through python3 -m yoke_core.domain.qa requirement-add --epic PREFIX-N --task-num K --workflow-transition STAGE ....
Machine case starting state
A case that runs on a Test Machine (host_control methods such as terminal-check, and exploratory-mission) declares the machine state it starts from. Authoring (yoke qa plan edit, yoke qa plan-cases replace), qa.requirement.add, and every materialization refuse a case that declares none, naming the choices and baselines; HC-qa-plan-machine-starting-state fails while a stored plan has one, and yoke qa plan get shows each choice:
host_baselines: ["fresh-host"](orshell-preconfigured, or both): the
runner resets to each named baseline; one requirement row per baseline.
"starting_state": "inherit": start on the machine exactly as the case
directly before it in the plan left it, at the same baseline position. The first case cannot inherit, nor can a case follow or be a mission.
"starting_state": "as_is"with"starting_state_reason": run on the
machine as found, for the stated reason.
A case added on its own (yoke qa requirement add) belongs to no plan, so it takes --host-baseline NAME or --starting-state as_is --starting-state-reason TEXT and cannot inherit.
A named-baseline or as-is case opens a chain; the inheriting cases behind it extend it. yoke qa plan run runs every case at one baseline position before the next, resets before each chain, and restores the chain's starting baseline after its last case, and after any case that did not pass, recording the receipt as starting_state_restore in that case's evidence. A failed reset leaves the case unstarted (starting_state_reset_failed); a failed restore stops the plan and names the yoke test-machine reset command. An inheriting case whose predecessor did not pass in the same run is recorded blocked_on_precondition naming that predecessor, and does not run. Deployment-stage case selections keep each inheriting case directly behind its predecessor. Requirements materialized before a plan declared its states read as behind their plan: edit the plan, then refresh them.
What the activity read returns
qa.activity.list is scoped to executable cases: a case is in scope when it carries a plan or a registered method_id. That covers plan-backed cases and equally covers a case attached straight to an item or a deployment run without one — an item's own ad hoc verification and a hand-authored release check are as visible as anything the pipeline materialized. What stays out is the method-less bookkeeping row (an acceptance-criterion marker), which never executes, so it has no run, verdict, or evidence to show.
Project scope comes from whichever subject the row names — its plan, its item or epic, or its deployment run — and the projects join is inner, so a row whose project cannot be resolved that way is readable by nobody rather than by every tenant. A planless case reports plan_id, plan, and case_key as null; readers name it by method_name / method_id instead. The day summary is computed from the same source as the rows, so its counts cover exactly the checks the rows sit beside.
Naming where a case runs
A plan carries its own target_environment_id, so every case materialized from it inherits one immutable execution target. A case attached without a plan names its own with --target-env NAME. When that name is a registered, authorized environment of the item's project, authoring and qa.requirement.update persist the canonical execution_target_json / execution_target_digest immediately. An unregistered name stays a draft label until a later bind; a name registered only on another project is refused. Executable roster creation (qa.plan_execution.begin) requires the canonical snapshot and names the CLI bind (yoke qa requirement update --requirement-id <id> --field target_env --value <environment>) plus yoke qa case run. Lifecycle writes use yoke lifecycle transition. Held to each of these:
- the environment must be registered to that project and authorized for it,
which is the same read a plan target passes;
- it must declare a reviewable address —
environments.url, orhosts.appin
its environment settings. An environment with neither is refused rather than bound, because a target carrying identity and no endpoint says the environment exists, never that the evidence came from it;
- when the case declares a
base_urlof its own, its origin must be that
address. A case browsing somewhere else is refused.
Naming an environment is what a case opts into. A case that names none — an item's own verification command, for instance — keeps running against whatever its runner already resolves, with no execution target recorded.
Binding a deployment case to its stage and member
A deployment run's QA stages declare their own scope. Acceptance reads deployment_stage = <name> for every one of them, and an item-scoped stage reads its member item too, so materializing a plan run-wide — no stage, no member — writes rows no stage reads: the owner sees a recorded pass and an unchanged stage that goes on waiting. A run pinning any QA stage, of either scope, refuses the run-wide form and names the binding invocation; a run whose flow pins no QA stage keeps it:
yoke qa plan run --deployment-run-id <run-id> --stage <stage-name> \
--member <PREFIX-N> --project <project>
--plan <plan-slug> joins that line only for a stage that names no cases. One already naming its own — pinned, frozen, attached, admitted, directly authored, or materialized by an earlier selection — refuses --plan, because a plan there materializes a second set of obligations beside the ones the stage credits.
yoke qa plan materialize takes the same --stage / --member pair when only the requirement rows are wanted. A run-scoped stage takes --stage alone; --member without --stage is refused.
Stage materialization stamps the run's own observed target — resolved from the receipt its deploying stage wrote — onto every case it creates. A plan authored before the release under test therefore verifies that release with nothing to retarget: the plan supplies the cases, the run supplies the target. A case bound this way carries a deployment execution target whose environment records the registered environment row and destination kind alongside its name, which is the shape Machine QA contracts accept beside a plan's own environment target.
When the project's release_pin capability declares candidate_pin_file, a persistent target also freezes the Yoke release the deployed candidate pins — that file read at the exact commit the run delivered for the project — as endpoints.release_version. Channels and the environment's desired-pin leaf both move with later releases; the deployed commit does not. An install step passes {{release_version}} to the installer (--version or YOKE_VERSION) through the bound installer_url. Freezing refuses, each naming its recovery, as deployment_qa_release_pin_missing (no pin at that commit), deployment_qa_release_pin_unreadable (no checkout or GitHub binding can read it), or deployment_qa_release_unpublished (absent from the installer origin).
That snapshot is frozen at first materialization for the run, stage, and member while the producer receipt is unchanged. Later target resolution reuses it, so a live edit to environments.url or environment settings cannot move execution_target_digest and drop a recorded pass out of scope. A newer ready producer attempt is a new identity: the stage rematerializes against that receipt rather than reusing the superseded snapshot. Re-running the stage against the same receipt reuses the existing requirement rather than minting a second copy. When evidence exists but a caller still asks under a different digest, the refusal names the recorded requirement and that it is out of scope — never "no cases". Result-write validation still live-resolves against a named receipt so a replaced candidate on the same run row cannot be written over.
A deployment-run case is not bound to the session's claimed lane: its subject is the candidate the run deployed, already built and observed at that endpoint, which no member's worktree contributed to. It is bound to that candidate instead, and the runner holds it there itself: with no --checkout-path, each command case runs in a disposable clone of its project checkout at the candidate revision, outside every lane and shared tree, removed once its verdict is recorded. The owner needs no flag and no claim on another session's tree however far the default branch has moved. An explicit --checkout-path at a different revision is refused, naming both revisions, because a command that reads the repository would otherwise report on code the run never deployed; --allow-tree-mismatch remains available and here declares that the case reads nothing from the checkout, as a probe against the deployed endpoint does. The tree the command ran in is recorded on the verdict either way.
For a candidate-bound Yoke checkout, the Command runner binds bare python3 and yoke to that checkout’s package roots with the shared source resolver. A temporary Python CLI launcher preserves the binding for nested shell and subprocess invocations. Import origins before and after execution are retained in candidate_source evidence and the output capture. Missing or outside-tree packages record QA-CANDIDATE-IMPORT-ORIGIN refusal evidence instead of a pass; repair the candidate checkout and rerun the same stage/member QA command. Lane cases, other projects, and endpoint-only --allow-tree-mismatch cases keep their product imports; the pytest watcher retains its own source binding.
command-ci verifies and publishes an item lane. Deployment-bound cases refuse as deployment_ci_candidate_unverified before checkout, rebase, push, or verdict; an empty diff against main proves nothing about the deployed candidate. Replace such a post-deploy case with the command method and rerun the same stage/member plan to check the pinned candidate. --allow-tree-mismatch does not waive this CI refusal.
For a deployment member whose cases require several Test Machines, the scoped command automatically executes one immutable roster per machine. Each roster holds its own lease and participates in that host's FIFO. It keeps the same run, stage, member and frozen target, including cases on different plans. A waiting host or independent review stops the command; after the wait or review settles, rerun the same scoped command to continue the remaining hosts. Completed scoped cases with passing evidence are retained. The member is accepted only when every required case passes. Omit --machine for this multi-machine form: a single host pin cannot satisfy conflicting constraints.
For one case that needs several hosts simultaneously, declare its driving host as method_config.machine and every required host in method_config.machines: {"machine":"macos-example","machines":["linux-example","macos-example"]}. Names must be registered, unique, and include the driving host. Run this case through yoke qa plan run; direct case execution refuses it. The runner acquires every host through its FIFO in sorted name order, releases partial acquisitions on contention, and retains the full set through case execution and mission review. Completion or abort releases the set. Cases with different driving hosts or declared sets use separate execution rosters.
Shared host turns and starting state
An occupied Test Machine is a wait, never a FAIL. Plan acquisition keeps its cursor open and queues the host on the existing durable execution. FIFO order starts at the first host-wait request and survives retries. Lease release reserves the next live waiter's turn and sends its owner the exact resume command; stopped owners use the same harness-neutral wake route as other control-plane notices. Ended owners leave the queue. A reserved sticky lease still uses the documented human-only recovery if its owner dies.
If a walker discovers a second required host is occupied, return WALK_STATUS: HOST_WAIT and its registered name plus holder evidence. Submit through the current bundle's yoke qa plan review-submit ... --stdin:
{"host_wait":{"machine":"linux-example","rationale":"lease holder and resume point"}}
Submit this object alone. It records no verdict or human review request, retains the old capture and bundle as history, and queues a new capture against the identical immutable target. The same scoped plan-run command resumes it. Use a verdict only after the required host was available and the walk ran.
Exploratory mission method_config can declare an apt package fixture:
{
"executor":"informed_subagent",
"machine":"linux-example",
"host_starting_state":{"os_packages":{
"absent":["python3-venv","python3.12-venv"],"present":[]
}}
}
method_config.machine selects that registered host even when this direct requirement has only the generic macos-examplehine capability. Admitted deployment copies retain the pin; omitting --machine does not permit another host, and a conflicting run pin is refused before a lease is acquired.
Only an explicitly declared host baseline restores the golden home. A mission declared as_is keeps the home it finds and, absent host_starting_state, leaves packages and their journal untouched. A named baseline or explicit fixture undoes only the preceding mission's journal-attributed package delta, applies the declaration and records proof before walking. Linux and WSL fixtures use apt; a declaration on another OS refuses with the supported host named. Package changes made through yoke qa mission host-command are journaled, including transitive installs and failed commands. The owner-only journal lives beside the golden archive, outside the reset home. No arbitrary package cleanup command is accepted. Independent host changes outside the journal's changed and declared packages, including unattended security upgrades, are preserved and become the new base inventory after restore. Missing attribution refuses before package mutation. Restoring missing or changed packages requires the recorded versions to remain available; a failed restore names apt access and journal reconciliation and blocks execution. Continuations preserve the existing walk and journal.
Case attachment — which subject a QA case answers for