Files
sencho/docs/tutorials
Anso 0ce9754766 fix(gitops): make automatic placement approvals reach execution (#2103)
* fix(gitops): make automatic placement approvals reach execution

Two defects on the bounded automatic placement path.

A removal was never auto-approved: the decision read
`gitops_targets.connectivity`, a column seeded to null that no producer ever
wrote, so a withdrawal resolved to `unknown_connectivity` even though the
target projection already derived reachability from the recorded observation.
The decision now reads the projection's own `targetConnectivity`, requires the
node registry to have the node online (the same gate an addition passes), and
carries an observation that cannot be decoded as a limitation, so the refusal
names malformed evidence instead of sending the operator to check a node that
answered.

An Inline Blueprint's policy placement approval was recorded and then never
read: an operational edit clears the combined approval, and the reconciler
returned at the legacy gate before consulting the policy's approval. The
reconciler now executes a plan a current policy placement approval covers,
under seven conditions: the application is inline, the placement policy is
still the one that could have written the approval, the approval is a policy's,
the rollout authorization policy is automatic, at least one node is already
placed at the current revision, every retained node runs the current revision,
and the approval covers every action in the plan. An uncovered plan waits for
the operator rather than partly executing, a first placement always waits for
Apply, and a retained node is observed but never repaired under this approval,
because the placement policy decided the node set and not the content.

A node-driven roster change no longer hides a policy approval behind a current
combined one: a label or cordon change moves the node set without moving the
combined approval's fingerprint, so the reconciler tries the policy approval
first and falls back to the combined approval when the policy cannot run the
whole plan. That fallback narrows the operator's blast to the placement's
frozen target set instead of invalidating it, so a removal a policy approved no
longer stops drift observation on the nodes that stay; the preview and the
lightweight list and detail evaluators read the same narrowing, so no surface
reports an approval the executor withholds or withholds one it will run. An
operator's approval is never discarded by a policy decision. Demoting an
application back to Inline takes the Git-era authority with it: the placement
pointer is cleared and the rollout authorization policy returns to operator
authorization, because neither was an operator's choice about Inline
placement. An Inline Blueprint can reach the automatic rollout authorization the
policy path requires from the control beside the placement policy.

* fix(gitops): say when automatic placement waits for Apply

A selector edit that moves one stateless node runs on the next tick, and Enforce holds a policy-approved plan until Apply. The docs described both as waiting, and the preview showed a pending plan with no reason.

* fix(gitops): keep a cleared pin from automatic placement

A pin clear left the new intent unpinned, so bounded auto could place the freed node without Confirm Apply. The decision now treats a clear as pin-driven when the standing approval was recorded on a pinned intent, or when a pin was written after an older approval and never confirmed.

* fix(gitops): keep a detached snapshot off automatic placement

Git-managed deploys stamp the Blueprint revision, and Git content never bumps it, so after Detach a retained node still looked current. Recording a new revision on Detach makes that stamp stale until Apply.

* fix(gitops): hold Enforce plans that drop a mid-deploy repair

A retained node that is correcting or deploying leaves the plan with no repair, so a policy approval could place an added node in Enforce. The mode itself now holds that plan until Apply, and the preview says so only when Enforce is the reason it is waiting.

* fix(gitops): keep a pin chain and a re-armed policy from placing on their own

Three authority gaps, all on the automatic placement path, found by the
pre-merge audit.

A pin that was set and cleared with no Apply between them read as no pin at
all once a third intent followed: the intent before the current one was the
clear, so the pin that moved the work was out of view and the freed node was
placed without Apply. With no decomposed placement approval to anchor on there
is no record of any confirmation, so the whole intent history now counts. A
Confirm Apply mints the approval the decision anchors on from, and the next
change is judged against that, so the wait ends at the next operator decision
rather than never.

Re-enabling a policy revived a decision the operator had taken back: execution
stopped the moment the policy went, but the approval the policy wrote stayed on
the row, so setting it back handed the next tick a decision made days earlier on
evidence nobody had read since. Arming either policy again now withdraws what
that policy decided, with the rollout authorization that only resolves through
it. An operator's approval is untouched, the approval rows stay as the record of
what was decided, the rollout generation stays so a fresh approval re-drives it,
and the history row records the pointers that went with the policy.

The refusals a withdrawal produces now read as what the backend checks: a node
that has not reported, or that is not a node this Sencho knows about, is a
different answer from a node that is not answering, and a withdrawal needs the
node to have reported the workload it was running.

* fix(gitops): clear the attention a policy placement left behind

A Blueprint the placement policy placed a node for sat at Needs attention
with a stale acknowledgement and a runtime drift, and neither Confirm Apply
nor an API apply cleared it: the retained nodes' plan is observation-only, so
nothing ever re-acknowledged them.

Two causes, both fixed. The Inline freeze is now keyed on the compose digest
rather than the intent revision, so a roster change no longer drops the
accepted generation and re-points every placed target at a new generation id
for the content it already runs. And the projection's identity test yields when
both intent revisions resolve with the same compose digest and the current
candidate still requires that node: a node the candidate dropped keeps its
signal, because a withdrawal the plan is asking for is not a superseded
acknowledgement.

Two roster changes inside one tick also left both additions unplaced with no
refusal recorded. The reconciler runs one approval and refuses a plan it does
not cover whole, so the second approval named only its own addition while the
plan still wanted the first. The blast now names every node the fleet does not
hold, carrying the earlier decision's unlanded placement forward, while the
decision still reads the approved set for its size and safety reasons and the
evidence is read for every node the blast names. Removals stay on the approved
set, because a withdrawal is judged from the observation of the workload that
ran there.

The Confirm rollout dialog no longer claims the fleet cannot change until it is
confirmed when a configured policy has already approved the plan.

* fix(gitops): write the placement blast in canonical order and name a withdrawn node again

Two findings from the QA run of this PR, both now pinned by failing-first tests.

The first: a roster move that adds a higher node id while withdrawing a lower one left the approval unwritable and the operator looking in the wrong place. The blast effect was assembled places-then-removals, while the decoder requires the node ids strictly increasing, so the write was refused as out of canonical order. The refusal reason then reported that refusal as `conflicting_operation`, so the operator was told an operation was in flight when none was, the plan read `reapproval_required`, and nothing executed. The effect is now sorted by node id, which is the order the decoder wants and carries no meaning of its own: the reconciler reads the effect as a map keyed by node id and the transition re-encodes it to canonical form anyway. Blast decode failures are also recorded as `malformed_evidence` rather than falling through to the conflict default, because a blast that did not decode is a data fault.

The roster window that reaches it: the reconciler executes one approval per tick, so an addition approved but not yet landed is carried into the next approval's blast, which then names a placed node alongside the withdrawal.

The second: a withdrawn node that the selector wants again produced no row in the plan at all. The tombstone holds deliberately, since only an explicit deploy re-opens a severed placement, but the node stayed online and labelled with nothing to say about why it was skipped, so the plan read as nothing to do. A new informational `skip_withdrawn` row now names what the operator has to do. It is excluded from the confirmable set and from both executor groups, so it authorizes nothing and cannot be confirmed into a mutate, and it yields to the status-precedence pass so an in-flight or guard row for the same node wins rather than leaving two explanations stacked on one node.

Follow-up, not addressed here: the replay-guard refusal (`placement approval already recorded for this intent and candidate`) still maps to `conflicting_operation`, which is the same class of untruthful reason on the crash-retry path. It predates this change and is left for its own pass.

Tests: a new case drives the add-then-withdraw window end to end and asserts the approval lands with `stateless_removal` and no refusal recorded; a second drives a severed target the selector wants again and asserts the informational row; a third pins that a severed node carrying a `withdrawing` row reports once, as the in-flight withdrawal.

Backend: `npx tsc --noEmit`, `npm run lint` (0 errors), `npm test` (11013 passed; the single file-level failure is `git-transport-ssh.integration.test.ts`, which requires loopback port 22 free and fails identically on an unmodified tree because this host runs sshd on 22, freed in CI by `scripts/ci-free-loopback-ssh-port.sh`).
Frontend: `npx tsc -b --noEmit`, `npm run lint` (0 errors).
2026-10-09 22:28:41 -04:00
..