` / vultr console screenshot)
+and surface the failure mode:
+
+- DNS/IP issue → wait + retry (1 constructive path).
+- Wrong pubkey → revoke the VM (`provision-.sh destroy`), fix Phase 2,
+ retry.
+- Cloud-init crashed on first boot → enable rescue mode via provider
+ console, read `/var/log/cloud-init-output.log`, fix template, retry.
+
+---
+
+## 3.d — AskUserQuestion (confirm IP + ready to harden)
+
+One `AskUserQuestion`:
+
+**VM is up at ``. Cloud-init finished, admin SSH works.**
+- Proceed to hardening (Phase 4).
+- Pause (inspect the VM first; re-invoke skill when ready).
+- Abort + destroy (calls `destroy` on the provisioner, returns to Phase 2).
+
+---
+
+## 3.e — Verify criterion
+
+- [ ] `VM_IP` set.
+- [ ] `cloud-init status` returns `done` (not `error`, not `disabled`).
+- [ ] `ssh ${ADMIN_USER}@${VM_IP} 'true'` exits 0.
+- [ ] `known_hosts` contains the VM's host key (pinned for future connects).
+
+Emit:
+`Phase 3 done: up @ , admin=, cloud-init=done.`
+
+Proceed to Phase 4.
+
+---
+
+## 3.f — Constructive-fail paths
+
+- **Create returned no IP (provisioner exit 2).** Root cause likely API
+ outage or quota. Paths: (A) retry after 2 min; (B) try sibling region;
+ (C) fall through to an alternate provider (loops back to Phase 1).
+- **cloud-init errored.** Pull logs via rescue; typical causes: bad yaml
+ indentation, unreachable apt mirror. Fix template; re-provision fresh
+ (destroy the broken VM first — partial state = harder to reason about).
+- **SSH never responded.** Check provider firewall / cloud-init user
+ creation — some provider images rename `root` → `debian` and our
+ `keiadmin` sudoers file didn't take. Remediation: add the provider's
+ default user to the admin whitelist for 1 run, then switch.
diff --git a/skills/vm-provision/phase-4-harden.md b/skills/vm-provision/phase-4-harden.md
new file mode 100644
index 0000000..5cb4bef
--- /dev/null
+++ b/skills/vm-provision/phase-4-harden.md
@@ -0,0 +1,109 @@
+# Phase 4 — Harden via `harden-base.sh`
+
+> Goal: run `_primitives/harden-base.sh` on the VM, over SSH, idempotently.
+> **Verify criterion:** script exited 0; `systemctl is-active` returns
+> `active` for `ssh`, `ufw`, `fail2ban`, `auditd`.
+
+---
+
+## 4.a — Ship the script
+
+The script lives on the workstation; copy to the VM and run with `sudo`:
+
+```bash
+scp _primitives/harden-base.sh "${ADMIN_USER}@${VM_IP}:/tmp/harden-base.sh"
+ssh "${ADMIN_USER}@${VM_IP}" "sudo bash /tmp/harden-base.sh \
+ --admin-user ${ADMIN_USER} \
+ --ssh-port ${SSH_PORT} \
+ $(for p in ${APP_PORTS[@]}; do echo --allow-port $p; done)"
+```
+
+Why not `curl … | bash`? Because that depends on a hosted URL AND a
+trusted TLS cert. `scp` the file you already audited locally. Lower
+surface area, reproducible.
+
+The script is **idempotent** — safe to re-run. Re-runs converge the VM to
+the declared state; missing directives get rewritten, extra ones are left
+alone.
+
+---
+
+## 4.b — Stream logs
+
+`harden-base.sh` logs to stderr with timestamps. Capture to
+`/harden.log`:
+
+```bash
+ssh "${ADMIN_USER}@${VM_IP}" "sudo bash /tmp/harden-base.sh …" 2> >(tee /harden.log >&2)
+```
+
+If the script exits non-zero: STOP. Do NOT proceed to Phase 5. Surface
+the last 30 lines of `/harden.log` + ask the user to choose:
+
+- (A) **Fix locally + re-ship** — edit the primitive (if bug is there) or
+ adjust flags. Commit the fix under `checkpoint:` before retry.
+- (B) **Patch the VM manually** — user logs in, fixes, we re-run the
+ script to ensure idempotency.
+- (C) **Destroy + reprovision** — when remediation risk > cost of a
+ fresh VM (2 min on Hetzner).
+
+---
+
+## 4.c — Post-hardening live-check
+
+After exit 0, SSH back in and confirm:
+
+```bash
+ssh "${ADMIN_USER}@${VM_IP}" "
+ set -e
+ systemctl is-active ssh ufw fail2ban auditd unattended-upgrades.service 2>/dev/null || true
+ ufw status | head -20
+ sudo auditctl -l | head -10
+"
+```
+
+All four services must be `active`. `auditctl -l` must show the baseline
+rules (sshd_config, sudoers, identity, module, time). Record the output
+in `/post-harden.txt`.
+
+---
+
+## 4.d — AskUserQuestion (ready to verify?)
+
+One `AskUserQuestion`:
+
+**Hardening applied. Four services active; auditd rules loaded.**
+- Run verification gate (Phase 5).
+- Apply one more pass (typo in `APP_PORTS`, extra user, etc. — loops 4.a
+ with a delta).
+- Pause (leave the VM in current state).
+
+---
+
+## 4.e — Verify criterion
+
+- [ ] `harden-base.sh` exited 0.
+- [ ] `ssh / ufw / fail2ban / auditd` all `active`.
+- [ ] `/harden.log` + `/post-harden.txt` captured.
+
+Emit:
+`Phase 4 done: 4/4 services active. Log: /harden.log.`
+
+Proceed to Phase 5 (hard gate).
+
+---
+
+## 4.f — Non-obvious failure modes
+
+- **`systemctl reload ssh` fails because `sshd -t` rejects the drop-in.**
+ Usually a custom `SSH_PORT` collides with ufw still configured for 22.
+ Fix: ensure ufw rule + sshd Port match BEFORE reload. `harden-base.sh`
+ writes both in one pass, but if an out-of-band edit happened between
+ runs, you get this.
+- **fail2ban service flaps.** Usually a systemd-journal backend mismatch
+ on very old Debian. Verify `backend = systemd` in
+ `/etc/fail2ban/jail.local` (script sets this).
+- **auditd refuses `-e 2`.** Means an earlier rules load is still
+ mastered; `augenrules --load` forces reload. Already in the script.
+
+None of these require a Level-2 escalation — all three have known fixes.
diff --git a/skills/vm-provision/phase-5-verify.md b/skills/vm-provision/phase-5-verify.md
new file mode 100644
index 0000000..c228b34
--- /dev/null
+++ b/skills/vm-provision/phase-5-verify.md
@@ -0,0 +1,112 @@
+# Phase 5 — Verification Hard Gate (`ssh-check` + `firewall-diff`)
+
+> Goal: fail-closed verification. Phase 6 refuses to run unless BOTH
+> `ssh-check` AND `firewall-diff` exit 0.
+> **Verify criterion:** `SSH_CHECK_OK = true` AND `FW_DIFF_OK = true`.
+
+---
+
+## 5.a — Pull config artefacts from the VM
+
+```bash
+scp "${ADMIN_USER}@${VM_IP}:/etc/ssh/sshd_config" /sshd_config
+ssh "${ADMIN_USER}@${VM_IP}" "sudo tar -C /etc/ssh -cf - sshd_config.d" \
+ | tar -C / -xf -
+ssh "${ADMIN_USER}@${VM_IP}" "sudo ufw status numbered" > /ufw-status.txt
+```
+
+The ufw status requires `sudo` on most distros — the admin user has it
+via `NOPASSWD:ALL` from `harden-base.sh`. If `sudo` requires TTY, prefix
+`sudo -n` and surface the failure.
+
+All captured files are READ ONLY, for `ssh-check` / `firewall-diff` to
+parse. We NEVER push config back from the workstation.
+
+---
+
+## 5.b — Run `ssh-check`
+
+```bash
+_primitives/_rust/ssh-check/target/release/ssh-check \
+ --config /sshd_config \
+ --drop-in /sshd_config.d \
+ --allow-user "${ADMIN_USER}" \
+ --json > /ssh-check.json
+SSH_EXIT=$?
+```
+
+Exit 0 → `SSH_CHECK_OK=true`. Exit 2 → `SSH_CHECK_OK=false` and
+`/ssh-check.json` lists the violating directives with
+`file:line` precision. Exit 1 → usage/parse error; surface the stderr and
+loop back to Phase 4.
+
+---
+
+## 5.c — Run `firewall-diff`
+
+```bash
+_primitives/_rust/firewall-diff/target/release/firewall-diff \
+ --intent /firewall-intent.yaml \
+ --status-file /ufw-status.txt \
+ --json > /firewall-diff.json
+FW_EXIT=$?
+```
+
+Exit 0 → `FW_DIFF_OK=true`. Exit 2 → the JSON lists `missing` (in intent,
+not live) and `extra` (in live, not intent) rules; `default_mismatches`
+flags a non-deny inbound policy.
+
+---
+
+## 5.d — Decision tree
+
+| `ssh-check` | `firewall-diff` | Action |
+|---|---|---|
+| 0 | 0 | Proceed to Phase 6. |
+| 2 | 0 | Loop to 4.a with the sshd_config.d fix + re-ship `harden-base.sh`. |
+| 0 | 2 | Ask user: apply the `missing`/`extra` deltas via `ufw` commands, or update `firewall-intent.yaml` (the intent was wrong). ONE AskUserQuestion. |
+| 2 | 2 | Both failed — show both JSON reports; recommend a single fresh `harden-base.sh` re-run first (common-mode fix), then re-verify. |
+| 1 | 1 | Workstation issue (missing binary, bad path) — NOT a VM problem. Rebuild the Rust primitives (`cargo build --release` in `_primitives/_rust/`). |
+
+---
+
+## 5.e — The AskUserQuestion
+
+Exactly ONE AskUserQuestion, gated on the decision tree above:
+
+**Verification results:** `ssh-check=`,
+`firewall-diff=`. Pick one:
+
+- **Proceed** (only shown when both PASS) → Phase 6.
+- **Fix and retry** → loop to Phase 4 (or to 5.c if intent YAML is wrong).
+- **Ignore and proceed** — **BLOCKED.** The hard-gate invariant refuses
+ this path per `SKILL.md`. You can abort, but you cannot bypass.
+
+---
+
+## 5.f — Verify criterion
+
+- [ ] `ssh-check` exit 0.
+- [ ] `firewall-diff` exit 0.
+- [ ] `/ssh-check.json` and `/firewall-diff.json` saved.
+
+Emit:
+`Phase 5 done: hard-gate PASSED. Artefacts in /.`
+
+Proceed to Phase 6.
+
+---
+
+## 5.g — Non-obvious pitfalls
+
+- **sshd_config.d drop-in not loaded.** Debian 12's default
+ `/etc/ssh/sshd_config` includes the `.d` directory via an `Include`
+ directive. We don't follow `Include` on purpose (security — includes
+ can escape the intended tree). Pass `--drop-in` explicitly.
+- **ufw status shows IPv6 rules as duplicates.** Intent is IPv4-only by
+ default; `firewall-diff`'s normalisation treats `(v6)` rules with same
+ port/proto as "expected" and does not flag them. If you need strict
+ v6-only rules, open a separate intent file.
+- **`MaxAuthTries` at 6 or 10** (Debian default). `harden-base.sh` sets
+ 3. If a previous manual edit raised it and we re-ran without rewriting,
+ ssh-check will FAIL `maxauthtries`. Fix: re-run `harden-base.sh`.
diff --git a/skills/vm-provision/phase-6-handoff.md b/skills/vm-provision/phase-6-handoff.md
new file mode 100644
index 0000000..4231304
--- /dev/null
+++ b/skills/vm-provision/phase-6-handoff.md
@@ -0,0 +1,123 @@
+# Phase 6 — Handoff + Final Report
+
+> Goal: emit a single, complete report and (optionally) hand off to
+> `/web-deploy` or `/auth-setup`. No further mutation to the VM from this
+> skill.
+> **Verify criterion:** final report emitted; all Phase-1..5 artefacts
+> listed with absolute paths; next-skill dispatch (if any) announced.
+
+---
+
+## 6.a — Artefact ledger
+
+Collect and surface:
+
+- `/plan.md` — Phase 2
+- `/cloud-init.yaml` — Phase 3 input
+- `/firewall-intent.yaml` — Phase 2 source of truth
+- `/harden.log` — Phase 4 stderr
+- `/post-harden.txt` — Phase 4 systemctl snapshot
+- `/sshd_config` + `sshd_config.d/` — Phase 5 input (captured)
+- `/ufw-status.txt` — Phase 5 input (captured)
+- `/ssh-check.json` — Phase 5 output
+- `/firewall-diff.json` — Phase 5 output
+
+Every path must exist on disk before emitting the report. Missing
+artefact = bug in an earlier phase; STOP and surface the gap.
+
+---
+
+## 6.b — Final report
+
+```
+=== /VM-PROVISION REPORT ===
+Intent:
+Provider: / region= / plan= / arch=
+VM: @
+Admin: (ssh port )
+Ports:
+TLS:
+Hardened:
+Verification: ssh-check=PASS firewall-diff=PASS
+Handoff:
+Artefacts:
+ - /plan.md
+ - /cloud-init.yaml
+ - /firewall-intent.yaml
+ - /harden.log
+ - /post-harden.txt
+ - /sshd_config (+ sshd_config.d/)
+ - /ufw-status.txt
+ - /ssh-check.json
+ - /firewall-diff.json
+AskUserQuestion count:
+```
+
+No prose after the ledger. The report is the contract.
+
+---
+
+## 6.c — Handoff (no AskUserQuestion; next-skill dispatch inferred)
+
+If `TLS_HOST` was set AND the caller's intent mentions deploying an app
+— dispatch to `/web-deploy` with the VM IP and admin credentials
+(by env-var reference only, RULE 0.8). Surface:
+
+> `Handoff → /web-deploy --admin --tls `
+
+If the intent mentions auth / identity — surface:
+
+> `Handoff → /auth-setup `
+
+Otherwise: `HANDOFF_TO=none`. User invokes the next skill manually when
+ready.
+
+**Never** run the next skill automatically — the user already clicked
+their way through 6 phases; handing off to another multi-phase skill
+without a pause is hostile UX.
+
+---
+
+## 6.d — Memory save (RULE memory-protocol)
+
+Append to `memory/{project-or-infra}.md`:
+
+```markdown
+### VM provisioned: (YYYY-MM-DD) [E1]
+- Provider: @
+- IP:
+- Admin:
+- Hardened: harden-base.sh rev
+- Verify: ssh-check + firewall-diff both PASS
+- Cost: /month (cited @ )
+- Artefacts: /
+```
+
+Evidence grade E1 — facts are direct observations (we ran the commands,
+we have the exit codes, we can re-verify on demand).
+
+If the project file doesn't exist yet, create `memory/{slug}.md` and add
+a single line to `MEMORY.md` under the right section.
+
+---
+
+## 6.e — Verify criterion
+
+- [ ] Report emitted.
+- [ ] All 9+ artefacts exist on disk at absolute paths.
+- [ ] `memory/{project}.md` updated (or created) with the provision entry.
+- [ ] `HANDOFF_TO` announced (or `none`).
+
+---
+
+## 6.f — Rollback instructions (always include in the report)
+
+```
+# destroy the VM + all its resources (idempotent)
+_primitives/provision-.sh destroy --force
+
+# purge local artefacts (plan, logs, captured configs)
+rm -rf
+```
+
+Keep them visible — Future-Us will appreciate the 1-command path back.