Feature execution playbook¶
Audience: an implementing agent (or contributor) executing a feature issue
cold, without this repository's session history. Every open feature issue
references this document. It captures the wiring recipes, verification gates,
and environment constraints learned while executing the program in
STRATEGY-MAKE-DIVERGENCE-LOUD.md (PRs #363–#372).
The gate sequence (every feature, no exceptions)¶
- Implementation + tests on a branch cut from
origin/main. python scripts/run_tests.py tests/ -qfully green (see known local failures below).- Mutation check: break your own logic (invert a comparison, disable a check) and confirm at least one test fails; restore. A test suite that passes against a broken implementation is not done. Verify the mutation hit code, not a comment or docstring — grep the emission site first (this mistake happened twice in one day).
python -m ruff check anvil_serving testsclean.python -m mkdocs build --strictclean; user docs updated.- CLI reference audit:
git add -Afirst (the audit isgit ls-filesbased — an unstaged new file is invisible and the inventory reads stale), thenpython scripts/audit_cli_references.py --update --scope docs|skills|full. - CHANGELOG entry + version bump in both
pyproject.tomlandanvil_serving/__init__.py. - PR with: what it closes (name the incident), what the gates caught, verification tails. Squash-merge only after all CI legs pass.
- Live validation on the reference fleet is an operator step — mark
anything requiring SSH or a running serve as
[operator]in the issue and do not attempt it from CI or tests.
Wiring recipe A: a new serves action¶
Mirror serves lint / serves rollback-check (see anvil_serving/serves.py).
Five edits, all required:
- Pure report function +
cmd_*printer inserves.py, nearlint_manifest_set. Report dict shape:{"findings": [{check, severity, serve, detail, files}], "errors": n, "warnings": n, ...}— tooling parses this; match it exactly. _ACTIONStuple +_ACTION_DESCRIPTIONSdict.- Parser branch: names/
--json/use_setmembership sets (grep for"rollback-check"to find all of them). Strict loader unless the command's purpose is reporting defects the strict loader refuses (onlylintloads leniently — that asymmetry is deliberate; see PR #365). - Dispatch branch in the main command function.
_resource_nodeinanvil_serving/commands/serves.py, then regenerate:python -c "from anvil_serving.commands import spec; spec.write_manifest()"(the checked-in manifest is byte-compared bytests/test_command_tree.py).
Confirmation gating: a node with mutation="mutate" plus a plain
--confirm option demands confirmation for EVERY invocation, including
read-only ones. If the command has a read path, use the conditional-gate
convention instead: requires_confirmation=True on the --confirm option
itself (the switch --recipe pattern; see PR #369, which shipped this bug and
its fix). Pin the read path with a CLI-level test that calls cli.main(...) —
leaf-function tests cannot see dispatcher gating.
Wiring recipe B: a new fleet verb¶
Mirror fleet version / fleet drift (anvil_serving/fleet.py,
anvil_serving/commands/fleet.py). Subparser in fleet.py::_build_parser +
dispatch in main(); _resource_node sibling in commands/fleet.py
(role="operator" — the existing role for commands not owned by one topology
resource); regenerate the manifest. The family is already registered in
commands/registry.py; adding a verb does not touch it.
Reuse, don't re-derive: _local_hostname_matches (local/remote
classification), the ssh probe pattern, repo_tracked_files/_repo_hashes.
Environment gotchas (every one of these caused a real failure)¶
- Remote Windows hosts: no
python3(a Store stub answers "Python was not found" and exits nonzero) — fall back topythonon launcher misses only. The remote shell is cmd.exe: ssh joins argv with spaces and the remote re-splits, so any-cpayload containing a space must be client-side quoted; multi-line payloads must be base64-wrapped into one shell-inert argument.ssh -nblocks stdin — heredocs cannot reach the remote. - ssh stderr is polluted by client-side advisory banners (
** WARNING: ... post-quantum ...). Filter**-prefixed lines before reporting stderr as a failure detail, or the banner masquerades as the error. - Hostname vs host id: repos key hosts by short name (
dark); machines reportnode-darkornode-dark.example. Match on the first DNS label, then--token membership. Bare containment is wrong ("w" in "elsewhere"); bare prefix is wrong (darkvsnode-dark). - CRLF: Windows checkouts flip line endings; content comparisons must
normalize
\r\n→\nor every file "differs". - Compose profiles:
docker compose configEXCLUDESprofiles:-gated services unless--profileis passed. Every real rollback serve is profile-gated — dropping profiles silently skips exactly what a rollback check exists to verify (PR #368's blocker). Parse the whole invocation from the serve'supargv: every-f/--file(=), every--profile, the service names afterup. --jsonat the top-level CLI is wrapped in the standard result envelope (likegroups); the leaf module (python -m anvil_serving.<module>) emits the raw report. Tests should call the leaf orcmd_*directly.- Known local failures:
test_cli.py::test_top_level_version_reports_ installed_versionfails locally whenever the source version is bumped but the editable install's metadata is stale. It is environment drift, not your change (see.tickets/2026-08-08-live-cli-is-an-editable-install-on-a- scratch-worktree.md). CI installs fresh. Never "fix" it by reinstalling from a scratch worktree.
Test conventions¶
- Inject
_run=subprocess.runand fake it; no real docker, ssh, or network in tests. Copy the_Result/ fake-runner helpers fromtests/test_serves_rollback_check.pyortests/test_fleet_drift.py. - Serve-entry fixtures need
runtime = "docker"(required since 0.24.0). - One sharp test per behavior; no permutation matrices. Every error message a user can hit should appear in at least one assertion.
- If the spec you are executing contradicts itself, resolve it in favor of the narrower guarantee, say so in your PR, and do not silently pick.
Design rules that bound every feature¶
- Detection beats prevention beats automation. Report first; refuse only states with no legitimate form (duplicate serve names: yes; a not-yet-created registry file: no — see the feature-5 revision in the strategy doc); never auto-heal (ADR-0033).
- Silent success on a no-op input is a defect: a typo'd
--restore-groupmatching nothing must error, not pass (PR #368). - Never read or transmit
.envor credential files — prefer designs where they are structurally unreachable (only repo-tracked names ever touched), not filtered out. 127.0.0.1is host-relative; never substitutelocalhost. Translations (e.g.host.docker.internal→127.0.0.1when probing from the host) must be reported in output, never silent.- Unreachable is an availability state, not an error (ADR-0034 §6): sleeping laptops exit 0; skew and not-installed exit 1.
- Every feature must be usable on a fleet of one.