# REPORT — Slice 4: reconcile engine + the reversibility gate (v0.4.0) (2026-06-08) > Overwrite-latest report (most recent significant work only). Cumulative history lives in [CHANGELOG.md](CHANGELOG.md). ## Outcome **Slice 4 is complete and pushed as `v0.4.0`.** Both phases landed: - **Phase A** (structural, pushed earlier as `v0.4.0-rc1`): the reconcile engine, the per-guest serializer (doc 03 §10), the desired-state model + provider seam, the field-normalization layer, the plan/diff engine, and the durable op journal + idempotency store. Runs **live but unfed** — `EmptyProvider` → zero mutations until slice 10 serves desired state. - **Phase B** (this push, the security core): the benign/destructive **classifier**, the **reversibility gate**, and the **signed-op consuming layer** over `internal/authz` — with role-scoping, op-to-action binding, idempotency/journaling, audit, and the crash-recovery consumer. The gate sits in front of the per-guest queue's executor, so **every mutation passes it**. The whole module is **race-clean and vet-clean** on the Linux build server; 62 reconcile tests pass (the adversarial matrix runs against the real `authz.Verifier`). ## The security model (Phase B) Hub-supplied intent is no longer trusted for destructive change — **by provenance + data-bearing-ness, not by verb** (doc 03 §4): - **Benign** (unsigned): start/stop/restart/create, and destroying a resource the agent created in the **same journaled transaction** (compensating rollback) or **tagged scratch**. That scratch/same-txn provenance is **agent-internal, journal-recorded, and never accepted from the hub** — a compromised hub cannot relabel a data-bearing guest as scratch to walk the gate. - **Destructive** (signature required): destroy/overwrite of the only/primary copy of customer data — **regardless of whether it arrives as a job or a desired-state delta**. Absent/invalid signature → refused **`pending_signature`**, never executed. The signed-op consuming layer calls `authz.Verifier.Verify` (the locked namespace→allow-list→crypto→target→time→nonce pipeline, untouched) and then enforces the slice-4 policy on the `VerifiedOp`: **role-scoping** (recovery key = key-rotation only; operational key = ordinary destructive + planned rotation, doc 04 §4) and **op-to-action binding** (the verified op + host + guest + params must name the exact gated action). Idempotency keys the journal by the op nonce; every decision is audited (a signal, never the guard). ## Inert by design (slice-4 scope) There is **no live destructive execution** this slice: nothing serves destructive deltas until slice 10, and the guest-destroy/storage-wipe/restore-overwrite executors land in 6/7. So the destructive path is fully **classified, gated, and adversarially tested**, but `RunSignedJob`'s executor is nil in production — an authorized destructive op is journaled as authorized-but-not-executed. Reconcile itself only produces the benign Start/Stop/SetConfig set, all allowed through the gate unsigned. ## Adversarial proof (each case independently rejected) Run against the **real** `authz.Verifier` with in-test-minted SSHSIGs (the ~40-line framing is replicated in reconcile's test binary — production `authz` is untouched and gains no signing capability; live minting is required because the verifier's clock is not cross-package injectable): unsigned destructive **job** → pending_signature · unsigned destructive **desired-state delta** → pending_signature (distrusts hub desired state, not just jobs) · forged / unknown signer → `ErrUnknownSigner` · expired → `ErrExpired` · **replayed nonce across an agent restart** (durable `FileNonceStore`) → `ErrReplay` · wrong host → `ErrTarget` · wrong guest / wrong op / wrong params → binding_mismatch · **recovery key on ordinary destructive** → role_denied · **hub-supplied "scratch" tag** on a data-bearing guest → ignored, still destructive → refused · **valid + correct role + correct target + fresh nonce → accepted**, and a second presentation → `ErrReplay`. ## The two forward-looking notes - **Note 1 (carried in)** — the `InFlight()` **resume-or-rollback** startup consumer (`Engine.Recover`) landed **together with** the signed-op executor, as required. An op that crashed after the Proxmox POST but before its terminal record (`OpTaskRunning`, nonce already consumed) is not covered by idempotency dedupe — only this consumer resolves it (re-read the task via the new `TaskStatusOnce`, record the real outcome; a no-task-id op is abandoned fail-safe). Wired into daemon startup and tested. - **Note 2 (addressed)** — the memory comparison is canonicalized (`desiredMemoryMiB`): desired and actual compare in the same MiB unit that is then written, so a non-MiB-aligned `MemoryBytes` converges in one pass rather than re-issuing SetConfig every cycle. A test proves convergence. Recommendation stands that slice 10 serve MiB-aligned specs at the source. ## Verification - `go test -race -count=1 ./...` and `go vet ./...` clean on the Linux build server (go1.26); all tests green locally and there. - No live Proxmox needed — Phase A is unfed and Phase B's destructive path is inert this slice. The gate's crypto path is proven end-to-end against the real verifier. ## Conventions Version → **v0.4.0**. CHANGELOG has a per-phase entry (newest on top). No secrets in any committed file. Pushed to `main`. Per the task, I stop at this checkpoint and await the validation pass.