slice 10A: activate the control envelope (Down channel) + hub-backed desired provider (v0.15.0)

The control envelope becomes live: the agent caches the hub's desired-state +
generation and re-fetches GET /hosts/{id}/desired-state only when the
generation advances. A new internal/desired Syncer maps the wire shape into a
reconcile.CachingProvider feeding the engine; benign deltas reconcile, an
explicit guest decommission is gated pending_signature (exec is 10B). Adds the
DesiredStateResponse/WireDesiredState wire types + Client.FetchDesiredState +
the loop EnvelopeObserver seam. Cross-repo golden (envelope + desired-state)
byte-identical with the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:02:59 +02:00
parent aa4dfb75ea
commit 8ecf8929fb
19 changed files with 836 additions and 59 deletions
+21 -7
View File
@@ -86,6 +86,7 @@ type Result struct {
Planned int
Executed int // succeeded
Failed int // errored
Pending int // destructive actions gated pending_signature (slice 10A — expected, not failed)
Errors []error // one per failed action
}
@@ -110,11 +111,12 @@ func (e *Engine) Reconcile(ctx context.Context) (Result, error) {
return res, nil
}
// Every mutation passes the reversibility gate before the queue (doc 03 §4).
// Reconcile only produces benign actions, so each is allowed unsigned — but the
// gate is genuinely in the path: a destructive class here would be refused
// (pending_signature) and never dispatched. A gate refusal counts as a failed
// action (it should not happen for the benign reconcile set).
// Every mutation passes the reversibility gate before the queue (doc 03 §4). Benign actions
// are allowed unsigned; a DESTRUCTIVE delta (slice 10A: an explicit decommission) is refused
// `pending_signature` when no operator signature is present — that is EXPECTED, not a failure:
// 10A serves destructive intent but never executes it (the signed-op execution is 10B). So a
// pending_signature refusal is counted as Pending and logged at INFO; any OTHER refusal (a
// benign action denied, or a destructive one rejected for a different reason) is a real failure.
type dispatched struct {
act Action
ch <-chan error
@@ -124,10 +126,16 @@ func (e *Engine) Reconcile(ctx context.Context) (Result, error) {
act := actions[i]
dec := e.gate.Authorize(intentForAction(e.hostID, act), nil)
if !dec.Allowed {
if dec.Disposition == Destructive && dec.Reason == ReasonPendingSignature {
res.Pending++
e.logger.Info("reconcile: destructive action gated pending operator signature (slice 10B)",
"vmid", act.VMID, "kind", act.Kind, "reason", dec.Reason)
continue
}
res.Failed++
res.Errors = append(res.Errors, fmt.Errorf("reconcile: gate refused %s vmid %d: %s",
act.Kind, act.VMID, dec.Reason))
e.logger.Error("reconcile: gate refused a benign action (unexpected)",
e.logger.Error("reconcile: gate refused an action unexpectedly",
"vmid", act.VMID, "kind", act.Kind, "reason", dec.Reason)
continue
}
@@ -174,6 +182,12 @@ func (e *Engine) execute(ctx context.Context, act Action) error {
} else {
upid, err = e.api.ResizeLXC(ctx, act.VMID, disk, size)
}
case ActionDecommission:
// Reaching here means a destructive decommission passed the gate (a verified signature) —
// which only happens once 10B wires the signed-op executor. In 10A there is no signer, so
// the gate refuses it before dispatch and this branch is unreachable. Fail safe loudly
// rather than silently no-op, so a future signed path can't accidentally execute here.
err = fmt.Errorf("reconcile: decommission executor is slice 10B (refusing to execute vmid %d)", act.VMID)
default:
err = fmt.Errorf("reconcile: unknown action kind %q", act.Kind)
}
@@ -261,7 +275,7 @@ func (e *Engine) reconcileOnce(ctx context.Context) {
}
if res.Planned > 0 {
e.logger.Info("reconcile: pass complete",
"planned", res.Planned, "executed", res.Executed, "failed", res.Failed)
"planned", res.Planned, "executed", res.Executed, "failed", res.Failed, "pending", res.Pending)
}
}