Commit Graph

34 Commits

Author SHA1 Message Date
Anso d07cfc34d1 docs: v1 docs refresh (batch 10) (#2182)
* docs(tutorials): refresh the create and approve a Blueprint walkthrough

Walk the 0.98.0 Blueprint editor, rollout preview, and detail sheet end to end and update the steps to match: the empty-catalog button, the staged label tags, the GitOps authority and evidence block, the approval state shown in the sheet, the audit rows each action leaves, and the real port-conflict recovery. Permissions are described as the stack and node permissions they are, and all seven screenshots are retaken.

* docs(tutorials): refresh the connect a Git source walkthrough

Walk the 0.98.0 Git Source sheet from an unlinked stack to an applied commit and update the steps to match: the toolbar and Anatomy entry points, the Custom CA field, the four apply behaviors, the first-pull Add row in the change plan, the Accepted answer and stage row, and the exact ref error. Add the rule that a linked stack must be detached before its repository or ref changes. All six screenshots are retaken.

* docs(tutorials): refresh the catch and fix drift walkthrough

Walk a real image drift through detection, re-check, redeploy, and dismiss on 0.98.0 and update the text to match: the redeploy confirmation's consequence label, the health gate entry on the Activity timeline, and the Dismiss toast with Undo. Dismissing and restoring a finding is noted as needing the same stack permission as redeploying. All seven screenshots are retaken.

* docs(tutorials): refresh the move a Blueprint to a new node walkthrough

Walk a real migration between a retiring proxy node and the hub on 0.98.0 and update the tutorial to match: the Placement control tab, the toasts on pin and cordon, Cordon in the node details sheet, the Needs confirmation status block before the rollout is confirmed, the transient Completion unknown state after it, and the audit rows including the stack the apply creates. All eleven screenshots are retaken.

* docs(tutorials): refresh the missing required variable guardrail walkthrough

Walk the guardrail on 0.98.0 from a clean deploy to a refused Save & Deploy and Update and back to a healthy update, and update the steps to match: the toast and the Update failed chip that carry the message, the readiness dialog wording, the Environment tab's missing count, the Deploy Guardrails section as it reads now, and the activity entries. Adds the screenshots for the blocked Save & Deploy and the failure chip, and retakes the rest.

* docs(tutorials): refresh the deploy from CI webhook walkthrough

Run the webhook end to end on 0.98.0, including a signed trigger and the newline-signed request the troubleshooting section describes, and update the text to match: the Action list, including Git source sync with a pointer to the Webhooks page, the new form, callout, and Recent executions states, and the uptime and log wording on the redeployed stack. All five screenshots are retaken with the trigger URL and secret replaced by generic values.

* docs(tutorials): refresh the enroll a remote node walkthrough

Enroll a real Pilot agent on 0.98.0 and update the steps to match: the Pilot enrollment dialog and its Done button, the tunnel states after the agent starts, the switcher and Fleet cards beside the nodes already enrolled, the pencil that opens Edit node, and the hub-only view list, which now includes GitOps. The example compose directory is changed to a dedicated media path, and all six screenshots are retaken with host addresses, seal pins, and the token replaced by generic values.

* docs(tutorials): refresh the Custom OIDC single sign-on walkthrough

Run the whole flow on 0.98.0 against a real Keycloak 26 over a private CA and update the text to match: the login button is labelled with the Display Name rather than a fixed phrase, the Active badge and header counts after Save, the stored-secret state after Save, the Log Out path, what happens when the browser is already signed in at the identity provider, and the full list of fields to leave blank. All six screenshots are retaken with the issuer shown as a generic host.

* docs(tutorials): refresh the auto-patch with a label walkthrough

Run the label-driven update schedule twice on 0.98.0 and update the text to match: the Labels submenu states, the Current matches panel that lists every node, the run time and per-node details in the Execution history, the second "all images up to date" run, and the node's Updates chip as the proof the pending update cleared. The Update board example is replaced because other stacks on a real fleet keep it from ever being empty. All eight screenshots are retaken.
2026-10-23 18:29:04 -04:00
Anso 99ca3664a5 docs: describe current behavior without contrast to the past (#2173)
Six sentences across the Settings reference, Deep links, Fleet Sync,
Sidebar and the environment guardrails tutorial described behavior as a
change ("no longer", "legacy", "now properly appears", "was removed").
They now state how the feature works today. No meaning changes.
2026-10-23 08:10:22 -04:00
Anso adb0d8a522 fix(docs): repair broken anchors and missing images (#2171)
Two links pointed at headings that do not exist (per-stack alert rules and
the Fleet Sync control anchor); both now use the real heading ids.

The Overview page and the environment guardrails tutorial referenced two
screenshots that are not in the docs. The Overview page reuses the Security
screenshot already shipped on the introduction page, and the tutorial drops
the single frame, since the step text already quotes the message.

The Resources page named its Rollback tab but never described it; it now has
a short section that links to the full explanation. A stray file left over
from the organization migration is deleted.
2026-10-22 20:43:30 -04:00
Anso 5b41c0512f docs: reorganize GitOps documentation into one section (#2167)
* docs: reorganize GitOps documentation into one section

Add a GitOps overview that defines the model once (Direct sources versus
Blueprints, the chain from commit to container, the three authority
steps, the policies, source states, executable identity). Split the
oversized Git Sources and Blueprints pages into focused pages for Git
authentication, provider and CI webhooks, repository secrets, and
Git-managed Blueprints, and trim the remaining pages to their own slice.

Group the GitOps pages in the navigation and split the old "Deploy and
automate" group into "Deploy and recover" and "Automate". Existing page
slugs are unchanged. Correct the provider hook labels to match the app,
and repoint What's New links to the new Git-managed Blueprints page.

* docs: add current GitOps screenshots and correct UI labels

Retake the Git Sources, Blueprints, and GitOps portfolio screenshots on
the integration image, and add screenshots to the new GitOps pages
(answer, path, and evidence block; SSH deploy key form; provider hooks;
Secrets tab; Convert to Git, placement review, and rollout controls).

Correct labels to match the app: Deploy key (SSH), the provider hook
event scopes, and the encrypted source policy buttons. The change plan
dialog shows the stack name, not the commit.

* docs: map the GitOps policy names once in the overview

Evidence, the policy controls, and the Git source form name the same
settings differently. State the mapping in one place.
2026-10-22 19:20:47 -04:00
Anso 7c99a65085 feat(settings): change fresh-install defaults for charts, log chips, progress style and stack guardrails (#2119)
* feat(settings): change fresh-install defaults for charts, log chips, progress style and stack guardrails

Fresh installs now start with the Signature chart palette, per-service
log chip colors, inline deploy progress, one retained rollback
generation per stack, and the missing-required-env deploy guard on.
Existing stored values are unchanged.

* fix(settings): make appearance reset match the fresh-install chart default

Reset to default was still applying the Calm preset, which put the muted
chart palette back. It now restores upright headings, reduced effects, and
the Signature chart palette, and the appearance screenshots match that look.
2026-10-12 00:32:36 -04:00
Anso 87db3f121e feat(drift): say how to resolve drift and dismiss it (#2116)
* feat(drift): let a team dismiss a drift finding on the ledger

Drift findings can now be dismissed until they change, for a set number of
days, or permanently. The dismissal lives on the finding's own row in the
drift ledger on the node that owns the stack, so the ledger's detected to
resolved lifecycle retires it: once the finding clears its row is closed, and
a recurrence is a new finding with no dismissal.

Each live finding is published with a stable id, a fingerprint of its
structured facts (kind, service, compose value, running value, never the
message), a count, and a per-kind policy. The Git managed-path conflict is
not dismissable. Dismissing and restoring need exact deploy authority on the
stack, the dismiss route re-reads the stack and refuses a finding that
changed or cleared since it was seen, and the drift status and finding count
keep counting dismissed findings.

Image mismatch findings also carry whether the running image was built after
the one compose names, read from the images' build times. Anything that
cannot be proven reads as unknown.

* feat(drift): resolve and dismiss drift findings from the Drift tab

Each drift finding now names its fix and carries the verb that runs it:
redeploy to match compose (confirmed first, through the editor's own deploy
handler), start a missing service, or open the stack editor or Git source
where the change is a decision about compose rather than the runtime. When the
running image is provably newer than the one compose names, the editor leads
and the redeploy confirm says it moves the service back to an older image.
Verbs the account cannot run are dropped instead of left to answer 403.

Findings can be dismissed until they change, for 7 days, or permanently, and
restored from the dismissed list. The status keeps its verdict and counts the
dismissed findings. A report from an older remote node, which cannot record a
dismissal, offers no Dismiss.

* docs(drift): document resolving and dismissing drift findings

Describe the verb each finding kind offers, the redeploy confirm, dismissing
and restoring, and who can run what. Rework the drift tutorial around the
redeploy button on the finding, with fresh screenshots of the finding, the
overflow menu, the confirm, the resolved state, and the Activity timeline.

* fix(drift): scope dismissals and late answers to the stack they belong to

Dismissing a finding now writes only that finding's own ledger row instead of
reconciling the whole stack from a snapshot that may be behind a concurrent
check, so it can no longer resolve or insert sibling findings or stamp the
check time. The Drift tab ignores a re-check, dismissal, or restore that
finishes after the operator moved to another stack or node, and closes an open
redeploy confirm on a switch.

Open stack editor needs permission to edit the stack, so a deployer is not
shown a button that does nothing. The dismissal hook takes either a hub surface
or a node endpoint, a restore that finds nothing re-reads the list, a dismissal's
mode and expiry are one type, and the drift dismiss route bounds the count like
the other dismissal routes. Adds tests for the dismissal lifecycle, the race,
the failure paths, and the handler wiring.

* docs(drift): say which buttons need permission to edit or deploy
2026-10-11 22:50:30 -04:00
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
Hambrook a6eaea0f54 feat(editor): create the stack .env from the editor on first save (#2097)
* feat(composeEditor): create .env file on demand when saved instead of relying on doing so via Files

* fix(editor): scope .env creation to the editor and adopt the resolved path

- PUT /:stackName/env creates the default .env only when the request carries
  create=1 and the stack exists; every other caller keeps the handled 404, so a
  Fleet Secrets push to a stack with no env file never creates one.
- The create response returns the resolved path; the editor adopts it, so the
  next save targets the file directly instead of failing the allowed-file check.
- Deep links to /env open the env tab on a stack with no env file.
- Mobile always shows the compose/.env toggle and the creation notice.
- Backend and frontend tests pin the create path, the canonical second save,
  the no-flag 404, the deep-link behavior, and the notice; docs, the guardrails
  tutorial, and the mobile screenshot are updated.

* fix(editor): make the first env save exclusive and pin the confirmed overwrite

Audit follow-up on the create-on-first-save path:

- PUT /:stackName/env?create=1 now creates exclusively with open(..., 'wx').
  An existing target answers the same 412 conflict the optimistic-concurrency
  path uses, with its current content and path, so a stale editor that still
  believes no env file exists can never overwrite one it has not read.
- The editor adopts the conflict path, and its confirmed overwrite retries with
  create=1&force=1&file=<path>, pinned to the file the dialog named and
  re-validated against the allowlist.
- With project env files configured, the create targets the first configured
  file (the one Compose reads), derived from the shared env resolver instead of
  a second copy of the fallback; a configured path that resolves outside the
  stack directory is refused with 409 rather than written.
- The conflict read repeats the containment check and refuses to follow a link,
  so the conflict payload cannot be used to read outside the compose root.
- A directory target maps to 409 instead of an opaque 500.
- Tests cover the conflict instead of an overwrite, the forced retry, the
  pinned retry, the configured target with a stray .env untouched, the escaping
  config refused, force ignored on the non-create path, and the frontend
  conflict, decline, and fallback flows.
- Docs restore the compose row's "Always available." note, describe the
  creation target and the no-overwrite guarantee, and make the notices generic.

* fix(editor): guard the env create against older nodes and unknown state

Audit follow-up on the create-on-first-save path:

- The non-forced create request now carries If-Match: W/"0". This build's
  exclusive create ignores it; a node that predates create-on-save treats it as
  a normal conditional write, so an existing env file answers 412 with its
  content instead of being overwritten, and a missing file keeps the handled
  404.
- A failed stack load clears the previous stack's env state instead of leaving
  a stale "no env file" assertion that the next save could act on.
- A failed env inventory or content read is tracked as unknown, shows a
  failure notice, locks the buffer, and refuses the save; it is no longer
  presented as an empty file.
- Declining the create conflict on a node whose 412 names no path keeps the
  conflicted content and refreshes the inventory under the current load's
  signal; a failed refresh reports the failure instead of a false reload.
- Save & Deploy and Save & Pull on an untouched empty env tab no longer create
  an empty file; the deploy proceeds.
- The exclusive create opens with O_NOFOLLOW so a planted symlink cannot be
  followed, and a failed write's leftover is removed only when the path still
  names that same file (inode and size match).
- Tests cover the older-node precondition, concurrent creates, a directory
  target, escaping and dangling symlinks, the write-failure cleanups, the
  unknown-state refusal, the conflict-retry copy, and the deploy skip.

* fix(editor): leave create mode on older nodes and return 409 for path conflicts

Audit follow-up:

- A create-mode 200 without envPath is the normal response of a node that
  predates the envPath field. The editor now refreshes the env inventory in
  that case, so it leaves create mode and later saves are normal conditional
  writes with a fresh ETag instead of prompting for an overwrite every time.
- The recovery refresh uses the load signal captured before the PUT, so a
  stack switch during the request aborts it and the new load's state is not
  repainted.
- PUT /:stackName/env maps INVALID_PATH and SYMLINK_ESCAPE from the create
  path to 409, so a symlink at the target is a recoverable conflict instead of
  an opaque 500.
- Docs: deep-links no longer says the created file is always the default .env.
- Tests: the older-node create, confirm and overwrite sequence asserts the
  editor leaves create mode and the next save is a conditional write; a route
  test covers an in-root symlinked .env returning 409 without leaking content.
- Comment cleanups from the audit.

* fix(editor): do not paint a create response that lands after a stack switch

Audit follow-up:

- saveFile checks the load signal captured before the PUT (opLoadSignal) in
  the 412 and success paths: a response landing after the editor switched
  stacks now returns without adopting the old stack's envPath, ETag, or
  buffer state and without prompting, so the new stack's editor state stays
  intact.
- Tests: two hook tests switch stacks mid-PUT, for both the success and the
  conflict response, and assert no adoption and no prompt.
- Docs: node-compatibility no longer claims every older node detects the
  conflict (nodes before 0.87 cannot) and notes that the confirmed-overwrite
  prompt does not repeat.
- Comment: createTarget wording matches the resolver's containment filter.

* fix(editor): create missing parent directories for a configured env file

Audit follow-up:

- A configured project env file can point into a subdirectory that does not
  exist yet; the exclusive open failed with ENOENT and the route answered an
  opaque 500. The create now makes the missing parents inside the checked,
  stack-bounded path and retries the exclusive open once, so the first save
  creates the file Compose reads at its configured path.
- The retry re-runs the containment check and still loses to EEXIST if another
  actor created the file meanwhile.
- Tests: a service test for a nested missing-parent target and a route test
  that configures config/prod.env with no config directory, creates it, and
  saves again through the returned path.

* fix(editor): report a vanished stack on env create instead of resurrecting it

Audit follow-up:

- Drop openExclusiveCreatingParents and its recursive mkdir. The state it
  served (a configured project env file in a missing subdirectory) cannot be
  produced by the API: PUT /project-env-files rejects names with "/" or "\"
  and requires the file to exist, so no product path reaches that branch.
  The only reachable ENOENT is a stack directory deleted between
  requireStackExists and the exclusive create; recreating it left the deleted
  stack visible only as a hidden folder holding the env content and blocked
  the stack name.
- Restore the direct exclusive open and map ENOENT in the route catch to the
  handled 404 Stack not found, so the race is reported and nothing is written
  or recreated.
- Replace the two tests for the unreachable nested target with regression
  tests: the service refuses a target under a missing stack directory and
  creates nothing; the route answers 404 Stack not found when the directory
  vanishes before the create (real service ENOENT path) and does not recreate
  it.

* fix(editor): recover when the create conflict target is deleted mid-dialog

QA follow-up:

- The confirmed create retry pins the file the conflict named. When that file
  is deleted before the operator confirms, the route rejected the pinned path
  with a 400 because it was no longer a present env source, and the editor
  (which had already adopted the path) failed every later save the same way.
  The create branch now accepts the pinned path when it is the resolved create
  target, so the retry recreates the file; any other pinned path keeps the
  scoped rejection.
- Declining a create conflict now reloads the env state instead of trusting
  the 412 payload: if the file was deleted meanwhile, the editor returns to
  create mode instead of adopting a path that no longer exists.
- Save failures toast the server's error text (404 vanished stack, 409
  unusable path, 413 too large) instead of the raw JSON envelope.
- The save-refusal toast for an unreadable env state uses the same wording as
  the editor notice; the file list may have loaded while the content did not.
- A viewer with no env file sees "This stack has no environment file yet."
  instead of an unexplained empty read-only tab; editors keep the create
  promise.
- The route test for a vanished stack restores its spy in finally.

---------

Co-authored-by: Anso <dev@anso.codes>
2026-10-08 12:12:26 -04:00
Anso 8a9115f48c feat(blueprints): call the tab Blueprints, add a Needs attention filter and keep the catalog on screen (#2082)
The Fleet tab was called Deployments while every surface inside it said
Blueprints, its empty state repeated a heading and offered two create buttons,
and the populated catalog opened with a third naming of the same thing. The
filter row had a Drifted chip that missed failed, conflicting, blocked and
awaiting-confirmation Blueprints, and a tile showed only a coloured dot, never
the state it stood for. Every refresh blanked the whole tab (closing an open
sheet's context) behind a plain "Loading blueprints" line.

The tab is now Blueprints. One header row carries the count and New Blueprint;
the empty state is just its card with one create action. Needs attention
replaces Drifted and covers failed, name conflict, evict blocked, awaiting
confirmation, held repair, drift and a rollout that needs reconfirming; a tile
names its strongest non-active state, and an empty filter offers Show all.

The first load shows a skeleton shaped like the catalog, and only if it is slow.
Later refreshes keep the catalog and any open sheet on screen; a failed refresh
over a catalog shows a notice with Retry and the last list, and the error card is
only for a tab with nothing to show. Overlapping refreshes are sequenced, and a
failed refresh after creating a Blueprint no longer opens its sheet later.

Docs and the e2e tab lookup follow the new name.
2026-10-04 17:35:40 -04:00
Anso d3fb532dfa feat(blueprints): give the Blueprint sheet one Answer and a shorter toolbar (#2080)
The Blueprint detail sheet opened on GitOps rollout and runtime stages that say
nothing true about a Blueprint that is applied rather than rolled out: a
healthy, fully deployed one showed an amber "rollout not executable" and
"runtime never applied". A failed deploy was told five times, its Docker log
pushed Re-apply and Withdraw off the right edge of the table, and the toolbar
carried five buttons, three of them rare.

An Inline Blueprint now leads with one status block built from its own
deployments: in sync, failed, drifted, needs confirmation, reconciler off and
so on, naming the affected nodes and offering the verb that resolves the
loudest problem (Re-apply, Review rollout, Review state, Evict, Enable) only
when this session may run it and the server would accept it. The GitOps status
takes over when it holds a louder warning, and the source that did not speak is
named in a quiet marker. Rollout "not executable" and runtime "never applied"
are left out for a Blueprint that has no Git rollout; a paused rollout or a
failed runtime still show.

The toolbar is Apply now, Edit, Enable or Disable, and Delete. Convert to Git,
Detach Git and Retire to Direct sit in the Content row next to the source they
change, and a pinned Blueprint gets an Unpin button. A failed row shows the one
line that says why, with the full output behind a control, and its action is
named Re-apply to match the status block.
2026-10-04 00:46:50 -04:00
Anso 03e2636e6a feat(blueprints): create node labels and see matched nodes from the Blueprint form (#2077)
The Blueprint form sent operators to Settings > Nodes whenever no node label
existed, and closing it to do so discarded everything typed. It also showed
the same labels in two rows (any / also all) with no list of the nodes a
selector actually reaches.

Targets now has one row of node labels with a Match any / Match all switch and
a live "Matches N nodes" list. "+ Label" stages a new label on chosen nodes
inside the form; the pending tags are listed and removable, and they count
toward the match list immediately. Nothing is written to a node until the form
is committed: Review rollout (or Create blueprint / Save changes where a
rollout cannot be reviewed) saves the Blueprint, adds the staged labels through
the existing node label endpoint, then opens the rollout preview. Save as
draft stores the Blueprint only. Staged labels that the final selector does not
use are never written. A Blueprint saved with both an any and an all list keeps
showing two rows.

Docs and the create-and-approve tutorial follow the new flow; two screenshots
that no longer match any step are removed.
2026-10-03 21:12:28 -04:00
Anso ee62a449b2 refactor(gitops): organize the Git source sheet into tabs and make editing deliberate (#2064)
The sheet was one long scroll with the repository form always open and a
toolbar of five buttons. A linked source now has tabs:

- Overview: the status block, with Review update, Retry, or Resume in it.
- Source: a read-only summary of the configuration, an Edit button into
  the form (with Cancel), Adopt onto Blueprint, and the manifest.
- Automation: the apply behavior, Suspend or Resume, and the provider
  hooks.
- Secrets.
- Drift, only while the application has drift.

An unlinked stack gets no tabs, only the form. The toolbar keeps Pull now
and Detach (named the same as its confirmation); Update appears only
while something differs from what is stored, so it never sits there doing
nothing.

Reading and refreshing are more careful. A failed read now says so with a
Retry instead of presenting an empty "link a source" form; a change that
was saved but could not be re-read keeps the linked source on screen; a
pull, apply, suspend, or resume refreshes the status without discarding a
change that is still being made; and a slow answer for a stack the sheet
has left can no longer overwrite the one it shows. Detaching empties the
form instead of leaving the removed source prefilled.

Docs, the Git sources page, and the two end-to-end specs that drive the
sheet are updated.
2026-10-02 18:58:27 -04:00
Anso b689733447 refactor(gitops): one layered status across the Drift tab, Git source sheet, and application view (#2063)
* refactor(gitops): one layered status across the Drift tab, Git source sheet, and application view

The GitOps state used to be a stack of tinted cards on five surfaces: a
posture card, approval chips, a card per facet, a card per target, and a
caveats box, restating the same state several times. Each surface now
shows one status: a single answer (with its resolving button, such as
Review update or Retry), a quiet row naming each stage that applies, and
an Evidence control holding recorded authority, policy lines, targets,
and anything Sencho could not prove.

- Stages that say nothing about an application (unbound placement, an
  absent rollout) are left out of the row instead of printed as "n/a".
- Evidence is never silently lost: the answer carries a marker for
  partial evidence, unreached nodes, and unproven items, and every
  attention reason is kept, the first in the answer and the rest in the
  evidence.
- The Git source sheet's Status section drops from about 450px to about
  170px, and its separate source-state and last-applied rows go; the
  header now says "never applied" until something is.
- The application view no longer shows the posture twice.

Adds a domain-free StatusPath primitive, a pure status model, and a
GitOpsStatus composer; retires the facet-card and approval-chip
components and keeps the policy lines. Docs, the tutorial, and the two
affected screenshots are updated.

* test(gitops): open the status evidence in the digest drift spec

The per-service digests now sit under the Evidence control of the GitOps
status, so the spec opens it before looking for them.
2026-10-02 18:30:16 -04:00
Anso c0b5708dd7 fix(gitops): date a recorded placement refusal instead of presenting it as current (#2042)
A placement decision that refuses records its reason once, and the reason is
cleared only when an operator approves the placement, edits the policy, or a
later change re-decides. A decision runs only when a placement change is
pending, so a refusal recorded because an operation was in flight outlives
that operation and nothing corrects it on its own.

The facet card rendered the reason in the present tense. An operator arriving
after a deploy finished was told the system was withholding a placement
because a conflict was in flight, and had to work out that the conflict was
over.

The record is kept and the moment it was made is shown. The timestamp was
already stored and already projected as the decision's own date; the frontend
carried the field and rendered nothing. Re-deciding instead would need a
trigger that does not exist today, and would let an automatic approval land at
a moment the operator is not watching, which is a larger claim than the
evidence supports.

Both tests fail if the behavior is reverted. The backend case drives the real
transitions: an operation in flight, a refusal recorded for it, the operation
finishing, and the projection still reporting the decline carrying the moment
it was recorded rather than the moment it was read.

Verified against a running instance at desktop and phone width: the card
carries the date in both, and the only desktop delta is the one added line.
2026-10-02 09:47:41 -04:00
Anso 8eaa954927 feat(updates): table view as the default for the update page (#2059)
The Update page now lists pending updates as a sortable table, one row
per stack across nodes, with the node-grouped card board kept behind a
Table / Cards switch. The choice is remembered per browser. Row details
hold the changelog, blocked reason, and per-service Apply.
2026-10-02 08:44:11 -04:00
Anso 3667651a2b refactor(fleet): trim node card badges to what matters (#2058)
Reachable nodes no longer carry an Online badge; Offline shows only
when a node is unreachable. The local/remote type badge is gone, since
the local card already has its pin. Remote cards now show a Proxy or
Pilot marker in the same corner slot so the connection type is clear
at a glance.

The persistent Networking exposure badge is removed from the card. It
fired on most nodes without detail; exposure and drift stay available
in the node details sheet and the Networking filter.
2026-10-02 00:43:59 -04:00
Anso ab34e85b86 feat(gitops): independent source, placement, and rollout authority policies (#2020)
* feat(gitops): add the versioned authority policy contract

Source acceptance, placement approval, and rollout authorization each get
their own value in one closed, versioned snapshot, so no reader can reach a
single value that looks like it governs all three.

Placement policy and rollout authorization policy are per application, each
with its own CHECK. Both default to the safe answer on a new install: an
operator approves placement, and rollout authorization waits.

Existing installs are a different case and the migration is the delicate part.
Blueprint applications already authorized their own rollouts on the acceptance
handoff, so leaving rollout authorization at its manual default would strip
that behavior from installs that never asked for the change. The backfill
restores it, scoped to live Blueprint applications, and runs only on the boot
that created the column. Every additive column here runs on every start, so an
ungated backfill would re-apply on every restart and quietly re-grant
authority an operator had turned off.

A rollout generation freezes the snapshot it executes, in a nullable column
with no default. NULL is what makes absence readable: a row predating the
column reconstructs as the behavior it actually had, which for a generation
that authorized itself was automatic, not the fresh-install default. An
empty-object default would make absence impossible and turn every existing
generation into a decode failure.

Decoding fails closed on anything present but unreadable, and refuses an
unrecognized key rather than dropping it, because a fourth key means the
writer and the reader disagree about what governs.

* feat(gitops): bound automatic placement to one stateless change

Placement policy decides whether a change in Blueprint target intent needs an
operator. Under bounded auto the only approved shapes are one stateless
addition or one stateless removal, and every other case falls back to explicit
operator review with a named reason rather than a silent skip.

The baseline is the last approved target set, never the previous candidate. An
intent revision clears the placement approval before a new candidate opens, so
the only set still in hand at decision time is a superseded one nobody
approved. Diffing against it lets a pending candidate absorb a later edit: a
candidate asking for one set sits awaiting review, a selector change mints a
larger one, the difference reads as a single addition, and the approval
authorizes a node on the strength of a decision no operator made. A test pins
that case in the refusing direction, and the mutation that would reintroduce
the bug is shown to return the approval.

Statelessness is derived from the compose content that would be placed rather
than from the Blueprint's stored classification. Compose edits are refused for
a Git-managed Blueprint, so that column says what the editor last wrote and a
repository push that adds a stateful service never updates it. An absent
reading is treated as stateful, because a missing field defaulting to the one
answer that approves is the failure this policy exists to prevent.

The decision is pure and total. Node lists are normalized on the way in, since
they arrive from a JSON column the compiler never sees, so damaged evidence
produces a refusal rather than a TypeError thrown from inside a judgement made
inside somebody else's write.

Directory-name ownership is left out of the reason union on purpose. Whether a
stack directory on some node carries this Blueprint's marker is a question
about who owns a name, not about whether moving a stateless workload is safe,
and the deploy path already refuses an ownership conflict by name.

* feat(gitops): apply bounded placement decisions outside the producer write

The decision is applied after the producer's transaction commits, in a
transaction of its own. Evaluating inside the producer would nest the
single-writer transition inside the caller's write, and a refusal or a defect in
the decision code would roll back the operator's own Blueprint create, update, or
pin. Nothing in the path throws at the caller for the same reason: the Blueprint
edit already succeeded, and an unapproved candidate waits for an operator
either way, so the failure direction is safe without a rollback.

Atomicity is therefore not the mechanism. Idempotency is. An approval now
refuses when one already exists against the same intent revision, because the
currency checks only catch a stale approval: replaying the same current one
passed all of them, minted a second approval row, and superseded the generation
the first had just opened. The guard is keyed on the intent revision, which is
the precise key, since a placement approval records no candidate and an intent
opens exactly one.

Authority and the snapshot that produced it are now required together rather
than trusted separately. A policy-authorized approval that records no snapshot
cannot be reviewed later, and an operator approval claiming a policy decided it
is a mislabelled authority record; both are refused at the write.

The two policy edits are configuration, not work. Each refuses while an
operation is in flight and refuses a write to the value already set, mints no
intent or candidate, and clears no source acceptance, placement approval, or
rollout authorization. An edit decides what the next decision may do; an
approval already standing keeps running under the snapshot that authorized it.
That is why a policy change never appears as drift, and it is pinned in the
direction that matters: a fully approved application keeps every reference and
its generation byte for byte.

* fix(gitops): refuse rollout authorization while an operation is in flight

The pause check only knows about a deliberate pause, so an authorization could
be minted while a fetch, apply, deploy, or recovery was still running for the
application. The two then disagree about what the next operation acts on, and
the authorization is the record everything downstream treats as settled.

Two refusals, both with a named reason and both failing closed. The application
guard reads the application's own active operation. The target guard reads the
target rows, because a target can be mid-apply while the application has nothing
running at all: they are separate machines and only the target knows about its
own apply. Reading only the application pointer would have missed exactly that
case, which is why the second refusal exists separately rather than folded into
the first.

* feat(gitops): add the placement and rollout authorization policy writes

Two endpoints, each naming in advance what the system may do, so each is
authorized by exact deploy on every frozen target before anything is written. A
policy that half-applied would leave the fleet split across two policies, half of
which nobody chose, so a caller entitled for one target and not another is refused
outright rather than applied where it happens to be allowed.

Both require only the authority a placement approval or a rollout authorization
already needs, and neither requires a global create grant. That grant is held
only by roles that hold a global deploy grant as well, so demanding it would
silently upgrade the gate to application-wide and make the per-target loop
unreachable for every caller that got past it. The create authority belongs to
the approval the policy authorizes, which already requires it.

Before the first placement there is no target to scope an exact grant to, and a
stack-scoped assignment is node-qualified, so it cannot be resolved for a node
that does not exist yet. The application-wide grant is the only gate evaluable
in that state, and the limitation is pinned as a test rather than worked around:
a scoped operator configures the policy once a target exists.

Each write refuses a value outside its own vocabulary, a write to the value
already set, and a Direct or Inline application, which has no placement or
rollout domain to configure.

* refactor(gitops): drop the unreachable marker conflict from placement

The reason could never mean what it said. The query behind it only ever looked
at this application's own intent revisions, so it could not detect a conflict
with any other Blueprint, and the one thing it did detect, a pinned node inside
the candidate set, is already reported as pin-driven placement. A reason in a
closed union that either never fires or duplicates another advertises a check
that does not exist, which is worse than not having it.

There is no database signal to replace it with. Blueprint names are unique and
deployments are keyed by Blueprint, so whether a stack directory on some node
carries this Blueprint's marker can only be answered by probing that node, which
the deploy path already does and refuses with a named error. Content-binding
contention is refused on the convert and adopt paths instead, and placement
cannot create the contention it would have reported.

The permission tests are reframed to state what the gate is for. Before anything
is placed, a bounded_auto policy authorizes work on whatever nodes the selector
matches later and that set is not known, so the application-wide grant is the
conservative answer and a narrowly-scoped operator is correctly refused. Once
targets exist the per-target loop applies, and an operator scoped to exactly
those targets is authorized, which is now asserted in the passing direction.

* fix(gitops): address the policy review findings

Seven findings, four of them load-bearing.

The rollout authorization policy was never read. The column was stored, the
route wrote it, the migration backfilled it, and generations froze it, but no
enforcement path consulted it, so setting it to manual returned success and the
next dispatch minted anyway. A policy-authorized mint now requires a policy that
says so; an operator authorizing by hand is unaffected, because an operator is
themselves the authority.

The placement baseline was unreachable. It resolved through the application's
placement approval pointer, which an intent revision clears before a new
candidate opens, so the pointer branch never ran. Every application therefore
looked un-approved and every multi-node candidate was refused as a first
placement, which is the opposite of the stated bound of one stateless change. It
now resolves the most recent approval row, which survives the pointer and is
still the record of the last set an operator accepted.

A cordon was never consulted. The refusal existed and the evidence gathering
passed a literal false, so an automatic approval could place a workload onto a
node an operator had cordoned. The flag is now read for the nodes being added.
Node evidence is also read for removals, not only for the candidate set, and a
stale target counts as unreachable rather than as reachable, which had made the
stale-node refusal unreachable for the targets it exists for.

The migration backfill could be stranded. Gating it on the boot that created the
column meant a process dying between the ALTER and the UPDATE skipped it for
ever, because every later boot sees a duplicate column. A completion marker now
decides, so the backfill is offered on every boot and runs only once. A row an
operator moved back to manual is indistinguishable from a never-backfilled row,
which is exactly why re-running it on its own predicate would have been wrong.

An approval's snapshot and the generation it opens are now compared inside the
write transaction. The authorizing snapshot is read by the caller beforehand, so
a policy edit landing in between could leave the two records disagreeing about
one decision, under a policy the operator had already revoked. The replay guard
is keyed on the intent and candidate together, since nothing enforces one
candidate per intent, and a placement approval now records the candidate it was
made against.

A refusal keeps its own cause. Every approval failure was reported as a
conflicting operation, which sent an operator looking for work that was not
running and turned the replay guard's precise signal into a lie on the retry
path. Also: an unreadable candidate target set is logged rather than swallowed,
and the empty-target-set permission comment no longer claims to be stricter than
the per-target path when it is the same shape as the health policy write beside
it.

The GitOps suite passes apart from two files that fail identically on an
unmodified checkout, one on a filesystem exhaustion this host is hitting and one
on a cold-start timeout. Typecheck and lint are clean.

* fix(gitops): report the unmet rollout prerequisite, not the policy

The rollout authorization policy gate ran before the authorization ingredients,
so it short-circuited with its own reason before the ingredient block could say
what was actually missing. An application with no placement approval was
reported as needing an operator rather than as needing a placement approval.

The gate answers "may policy authorize this, or must a human", which has no
meaning until there is something to authorize, so it now runs after the
ingredients pass and before the artifact qualification check.
`authorizationIngredients` is a pure read with no side effects, so nothing
mutates between the two positions and the only behaviour that changes is which
reason is reported.

This was the same class of misdiagnosis the previous review round flagged, where
every approval failure was relabelled with one fixed reason. The prerequisite
that is missing is the more specific and more actionable fact, and reporting
anything else sends an operator looking in the wrong place.

The operator path is unaffected either way: an operator asking to authorize is
themselves the authority, and no policy stands in the way of one.

Verified: the previously failing assertion returns to the placement reason, the
two suites covering the gate pass, and removing the gate from its new position
still fails the suite, so the move did not make it unreachable.

* fix(gitops): make bounded automatic placement reachable

Automatic placement could never approve anything, and never had. Two defects
compounded, and the first one hid the second.

Node evidence was read from the target row. An added node has no target row and
never will until the placement lands, so every addition resolved to unknown and
was refused. Nothing writes connectivity on a target row either: the only
constructor seeds it to null, so the column is null for every node for ever. The
pure decision was exhaustively correct and completely unreachable.

The effect was also hand-assembled as a JSON object where the schema wants an
array, so the transition's own decode rejected every approval. Because that
refusal is the safe direction it surfaced only as an operator review, which is
how a feature can look wired up end to end while never once approving anything.

Each side of a change is now judged by the evidence that actually exists for it.
A node being added is known from the node registry, which is maintained rather
than seeded, and is refused when it is unregistered or not answering. A node
being left is judged by the observation of the workload that ran there, where a
stale observation does not count as reachable. Cordoned nodes are refused for
additions, which the previous code could detect but never supplied.

The eight new tests drive the real path rather than a hand-built input: a
stateless addition reaches auto_approved and records the snapshot that decided
it, and each refusal is reachable from real state. Two of them fail if the node
evidence regresses to the target row, and two fail if the blast encoding
regresses, so neither defect can come back silently.

* test(gitops): drop an unused binding from the placement end-to-end suite

* fix(gitops): do not re-decide an authorization that already exists

An operator authorized a rollout, got a success response, and nothing deployed.
The mint succeeded because an operator is the authority. The dispatch that
follows re-enters the same function on the automatic path, where the policy gate
saw the fresh-install default of manual and refused, so the policy governed the
execution of authority that had already been granted.

The policy governs whether authority may be created, not what may be done with
it. A grant is not withdrawn by a later configuration change, and it should not
be re-litigated on every dispatch. The same reasoning applies to the in-flight
guard: it exists so an authorization is not minted while a fetch, apply, deploy,
or recovery is running, and a routine background source fetch must not pause a
rollout that is already authorized and running.

Both gates now stand down when a live binding already exists, using the existing
live-binding helper rather than a new notion of what counts as authorized. The
preflight evaluation and the drift remint still run either way, since that is
this function's other job and it is about evidence rather than authority.

The gates keep their teeth for the case they exist for: with nothing granted,
the policy is the only thing that could grant authority, and on manual it
refuses. Both directions are asserted, for the application-level and the
target-level in-flight case, and each fails if the live-authorization bypass is
removed.

* fix(gitops): let an operator re-approve a placement on the same intent

The replay guard refused any second placement approval for an intent and
candidate that already had one. That is correct for the automatic path, where a
duplicate is a replay, and wrong for an operator.

The Inline Apply path reuses the application's current intent rather than minting
a new one, so an operator pressing Apply again lands on exactly the same intent
and candidate. The guard refused it as a replay and regressed a flow that had
worked for ever, reporting it as a duplicate decision when it was a deliberate
second one.

The guard is now scoped to policy-authorized approvals, which is the only caller
that can produce a duplicate by being invoked twice. The operator path is
unrestricted, and the automatic path keeps its protection.

The test that previously asserted the refusal now asserts the operator's second
approval succeeds, and a separate policy-authorized case keeps the guard
load-bearing. Widening the guard back to all authorities fails two tests, and
removing it fails one.

* fix(gitops): enforce the mint-time gates in the single writer

The previous fix let a live authorization bypass the policy and in-flight gates,
which was right for executing authority that already exists and wrong for
creating it. The distinction I drew was between a binding existing and a mint
happening, and I enforced it on the former.

So the remint path was ungated. When the preflight fingerprint drifts, the old
authorization is discarded and a new one minted, which is a fresh grant of
authority, and on a manual policy that mint now succeeds with
authority: configured_policy and no operator anywhere. A registry tag moving was
enough to replace an operator's grant with a policy-authorized one, which is the
precise thing the gate exists to prevent. The race-retry branch had the same
hole.

Both gates now sit in rolloutAuthorized, the single writer every mint path goes
through: the acceptance handoff, the Blueprint dispatch, the startup
reconstruction, the preflight backfill, the operator route, the drift remint and
the race retry. A caller-side check cannot be unbypassable; this can, because
there is nowhere else to mint from. That also closes the separate finding that
the policy was not re-checked at mint time, since the re-check is now the gate
itself.

The caller-side gates stay where they are, because they still give the better
reason for the ordinary case: a missing prerequisite is reported as itself, and
a pause is reported as a pause, before anything reaches the transition.

Two tests assert the property where it now lives, that any policy-authorized
mint reaching the transition on a manual policy is refused, and the same
mid-operation, with the operator path shown to be unaffected. Removing either
gate fails one.

* fix(blueprints): refuse to call unresolvable compose stateless

Stateful service detection is the evidence an automatic placement decision rests
on: a service it does not name is one the system believes carries no data. Four
compose constructs attach a mount to a service whose own block mentions none,
and the detector returned an empty set for all four, which is the permissive
answer.

The merge key is the sharpest case, and worse than a simple blind spot. The
anchored service is found, so a partial answer named it and reported the service
that merges it as stateless, attributing the volume to the wrong service while
the workload that actually inherits it read clean. `include` and `extends` name
another file. `volumes_from` attaches another container's volumes, which is
directly data-bearing.

A document with no services now also returns null. Nothing to place is not the
same as proven stateless, and a caller deciding whether a placement is safe
should not have to tell those two apart.

There is deliberately no partial answer. A set naming only the services the
detector could see reads as permission for the ones it could not, so "cannot
prove" and "is stateless" have to stay different answers. Both callers already
hold on null: the stateful withdrawal guard treats it as unreadable, and the
placement decision treats it as an unknown workload, which is a refusal.

Resolvable content is unchanged, including a tmpfs mount, which is not data the
workload owns, and is still not a stateful service.

* docs(gitops): put two doc comments back on the code they describe

The health-and-rollout policy route's own doc block was left stranded above the
policy-target gate, a hundred lines from the route it describes, so a reader
arriving at either place found the wrong explanation. A helper inserted between
the rollout authorization doc block and its function had the same effect there.

One of the stranded claims was also false by the time it was found. It described
the empty-target-set branch as authorizing against the Blueprint's deploy stack
name; the branch falls back to the application-wide grant, because a stack-scoped
assignment is node-qualified and cannot resolve for a node that does not exist
yet. The explanation now says what the code does, including the consequence an
operator needs to know, which is that configuring a policy on a fresh application
is an administrator action.

No behavior change.

* fix(gitops): let a demoted Blueprint still have its policies set

Demoting a Blueprint back to Inline keeps every policy column, and the placement
decision does not read the target mode, so a `bounded_auto` application keeps
approving its own stateless placement changes after the demotion. Until now no
route could stop that: all three policy writes resolved their target through the
same gate the source and rollout actions use, which requires Git management, and
answered NOT_GIT_MANAGED for a demoted application. The only way back was to
edit the row directly.

Resetting the policy on demotion would be the wrong fix. Where content comes
from and who decides a placement are separate domains, and coupling them here is
the entanglement this model exists to remove. A policy is a statement about who
decides, so a source-domain condition has no business gating it.

The shared resolver is now split by what it actually requires. Actions that act
on Git content, or on a rollout Git produced, keep the Git-management gate: source
acceptance, placement approval, and the rollout lifecycle. The three policy
writes resolve without it, which is the same shape the health policy already had
in spirit and never had in practice.

Widen which applications a policy write reaches, not who may write one, so the
permission behaviour is unchanged and pinned again here: a caller without the
deploy grant is still refused, and a Direct application, which has no policy
domain at all, is still NOT_GIT_MANAGED.

* feat(gitops): project each authority policy and why it declined

The three policies could be configured and enforced but not read. An operator
looking at a placement waiting for review under an automatic policy had no way to
learn that the policy had run, or why it left the decision to them, and a policy
that never fires looks identical to one that is working. That is the same
failure shape as the rest of this branch: a feature that reads as wired up while
reporting nothing.

The projection now carries one read per domain, in stage order, each answering
the two questions an operator actually has: what is configured now, and what the
work in flight was really decided under. The second is read from the snapshot
the generation froze, never from the current setting, because a policy edited
afterwards must not appear to have authorized work that is already running. It is
reported as absent whenever no readable frozen snapshot exists, which includes
every generation predating the policy contract.

The refusal itself had to become readable first, because the decision was
computed, returned to a producer and discarded. It is now recorded on the
application row, in the closed decision vocabulary, and constrained by a check so
a reason no decision can produce cannot be stored or projected. Cleared inside
the two transitions that resolve it, an approval and a policy change, so a reason
that described the old policy cannot be read as the current one.

The refusal is written with the operation's own timestamp, not the wall clock, so
its freshness means the same thing as every other recorded time on the row.

Also fixes a trap this change walked into: the application insert hardcoded its
placeholder count, so adding a column to the list and not the count compiled
cleanly and failed at runtime as a SQLite arity error on the first insert, which
is the first write any create path makes. The count is now derived from the
column list it belongs to.

* feat(gitops): show and set each authority policy where it is decided

The policies could be configured only by editing a row, and the two new ones had
no affordance at all. An operator could not see who was configured to decide a
stage, and could not stop automatic placement once it was on.

Policy reads join the facet cards. The four cards already render source, artifact,
placement and rollout in a fixed order and are shared between the application
view and the rollout preview, so the same evidence cannot read two different ways
on the two surfaces. Each card that governs a decision carries that decision's
policy line; the artifact card carries none, because it reports what was built
rather than who may act. The line says two things and stops: who is configured
now, and what that policy actually did. The second is a separate fact, because a
policy edited after a generation opened does not retroactively claim it, and a
line showing only the configured value would read as though it had. A card with
no decision and no reason shows nothing, so the line is never a restatement of
the card's own state.

Policy edits sit beside the action each one governs: placement policy beside the
placement approval, rollout authorization policy in the rollout overflow where
the health policy already lives. Not on the GitOps Workplace, which is triage and
would gain a second job. One control and one label map serve both, so three
vocabularies cannot appear in three places. The control is absent rather than
disabled for a session that may not write, matching every other gated affordance
here, and it names a value this build does not recognize instead of snapping to a
segment that would overwrite whatever the server is enforcing.

The source policy's Manual mode finally has an option. It was in the contract
since the policy columns landed and the panel could not reach it, because the
panel derived the mode from two booleans that cannot express it: a manual source
and a review source both report `auto_apply_on_webhook: false`. The policy field
was already on the wire and simply undeclared, so the fix is to read it. Manual
is not a flavour of review either. It takes the source out of the unattended
cadence: no polling, no webhook. Both the panel and the create dialog now offer
it and save the automation flags that agree with it, so choosing it does not leave
a source that still applies on its own.

The apply-mode options also state their selection, which was carried only by a
border and a fill and so was invisible to a screen reader.

* fix(gitops): hold a policy write to the authority it names

The placement and rollout authorization policy writes were gated more loosely than
the health policy write beside them: exact deploy per target, and the
application-wide grant only when there was no target to check. A stack-scoped
operator could therefore set either policy to automatic.

That is a real widening rather than a theoretical one. A policy governs every
target of the application, including nodes that do not exist yet, so one
operator's grant on a single stack would authorize placements and rollouts
elsewhere in the fleet. The per-target loop does not close it, because it only
checks the nodes present today.

Both halves are now required, the same requirement the health policy write
already carries and the same one recorded for it in the permission inventory. The
application-wide half is the one a scoped operator can fail, which is exactly why
it belongs on the path. Configuring a policy on a fresh application, before any
target exists, remains an administrator action: there is no per-target check to
make and a stack-scoped assignment is node-qualified so it cannot resolve for a
node that does not exist yet.

The behavior change is pinned from both sides: a stack-scoped operator is refused
on both routes, and a role-based deployer still passes, so tightening the gate
does not lock out the operator it is meant to admit.

Also records the surface and the model. The authority policy model, its single
writer, the bounded automatic placement decision, why the refusal has to be
durable, and what the projection refuses to claim about work in flight are now
written down rather than only living in the code. Alongside it: the two new
service modules and the shared control in the inventories, the two policy routes
in the permission inventory, the new endpoints in the route inventory, and the
architecture map with a flow for the write and the HTML re-inlined to match.

Operator documentation: the source policy's Manual mode, which the panel could not
reach because the two booleans it derives from cannot express it; who decides each
of the three steps, and the whole of what the automatic placement policy may do on
its own; the policy-domains table, which still described placement as a per-rollout
approval rather than a configured policy.

* fix(gitops): do not let a cordon withdraw a workload

Cordoning a node removed it from the Blueprint's desired set, and a single
stateless removal is inside what the bounded automatic policy may approve on its
own. So one cordon silently stopped the workload running on that node, and the
placement policy read as broken rather than as having drained a node.

A cordon says where work may not go. Taking work away from a node is a separate
decision, and the operator who cordoned a node has not necessarily made it. The
cordon is now read for removals as well as additions, and a withdrawal whose only
cause is a cordon falls back to the operator with its own reason.

The reason is separate from the existing cordon override because the two ask
opposite questions of the operator. That one says Sencho would have placed work
on a node you told it to leave alone. This one says the withdrawal you are seeing
follows from the cordon you set. The new reason joins the closed decision
vocabulary, which constrains both the stored column and the wire, so no decision
can produce a value that cannot be stored or projected.

Reading the cordon for removals is not a blanket refusal of removals, and that is
pinned from both sides: a withdrawal of a node that is not cordoned is still
approved. The pure decision test passes the evidence in directly and so cannot
catch a caller that sets it for every removal, which is why the approving case
lives at the level that reads the cordon.

Also corrects two comments that claimed a guarantee their callers defeat.
`recordPlacementShift` said each transition commits on its own, and its
automatic-placement loop said the triggering change had already committed. Both
callers wrap it in a transaction, so neither is true. The behavior is fine and
arguably better, because the decision rolls back atomically with the write that
caused it, and every refusal is caught rather than propagated. What was wrong was
the claim, and a comment that asserts a guarantee the code does not provide is
worse than none.

* test(gitops): record no policy snapshot on a seeded rollout generation

Two test files build a rollout generation row inline, and this branch added a
column to that row, so the merged tree failed the typecheck in both. The fix is
to say what those rows actually are: generations with no recorded policy
snapshot, which is what a generation predating the policy contract looks like.

Null is the honest value rather than a stand-in for something. Both files are
about which generation a target may restore or which one it acknowledged, and
neither is about policy provenance, so recording a snapshot on their rows would
assert a fact the tests have no evidence for and would let a later reader infer
that these rows were minted under a policy.

Test fixtures only. No production code and no behavior change.

* fix(gitops): a migrated Blueprint is not a first placement

The placement baseline falls back to the running target rows when there is no
decomposed placement approval, and reported that it had never been placed. A
migration that carries an approval forward records a pre-decomposition row
instead, which names no target set and so cannot supply a baseline but does prove
the placement was not the first thing that ever happened to that application.

So a migrated Blueprint running on three nodes whose selector narrowed to two was
refused as a first placement across more than one node, for ever, on exactly the
installations the migration was written for. The refusal is the safe direction,
which is why it read as a policy that quietly does nothing.

The two questions are now separated. What the placement was authorized to be is
answered by the decomposed row, and what the running targets are is the baseline
when there is no such row. Whether the application was ever placed under an
operator's authority also accepts the carried-forward row, and only that row, so
a genuinely fresh application is still a first placement. That last half is
pinned directly on the reader, because asserting it through a refusal would have
several other possible causes.

Also moves the rollout authorization backfill's completion marker out of
`global_settings` and into `system_state`. The marker is internal migration
progress, not a setting, and `global_settings` is the allowlisted table the
settings route projects.

The backfill's lifecycle predicate is left exactly as it is, and the reason is
now written down rather than left to be re-derived. A `blueprint` mode row is
never `creating`: a Blueprint application is inserted as `inline_blueprint` and is
`active` from the start, and the transitions that write `creating` are the Direct
create and activate paths, so the predicate excludes nothing today. It is also the
right line if that ever changes, because a Blueprint application still being
created never reached the acceptance handoff and so never auto-authorized.
Granting it `automatic` there would be a privilege grant rather than the behavior
preservation this backfill exists to perform.

* fix(gitops): keep the two compose readings apart, and see an in-flight target

Two release-blocking defects, both found by the pre-merge audit and both mine.

**The stricter compose reading broke automatic source acceptance.** Tightening
stateful detection so an unresolvable compose answers unknown instead of naming
the services it could see was applied to the shared helper. The only other caller
is the stateful withdrawal guard, which treats unknown as unreadable and compares
both the staged candidate and the generation in force, so a compose using a
merge key, an extends, an include, or an override file with no services was held
for ever. Those are ordinary compose idioms, and an install using one stopped
applying updates automatically.

The two consumers need opposite answers to the same document, so they are now
separate entry points rather than one method with a flag. The guard gets the
best-effort reading it has always had, and the placement path gets a strict one
that refuses. A caller about to act on the result must never be handed a partial
set, because a set naming only the services the detector could see reads as
permission for the ones it could not.

**The bounded-auto in-flight check could not see a rollout.** It read only the
application's own operation pointer. A sequential rollout sets its stage on the
target, so a label change during a mid-deploy rollout was approved, cleared the
authorization, and superseded the generation that deploy was running under. The
target-side reader already existed in the rollout handoff, so it is now shared
rather than re-derived.

**Placement content was read from the wrong directory.** Generations are staged
under the application's source stack, not under the Blueprint's name, so a
renamed stack found nothing and every placement on one read as an unknown
workload. And multiple compose files were concatenated into a single document,
which duplicates every service key and does not parse, so a multi-file project
could never be approved either. Each file is now analyzed on its own, which is
what the withdrawal guard has always done.

The end-to-end suite staged its content under the Blueprint's name, which is the
directory the source path never writes to. That is why the wrong path was not
caught: the test agreed with the implementation. It now stages where generations
actually live, and covers a renamed stack, a multi-file project, a multi-file
project whose second file is stateful, and an addition during a target deploy.

* fix(gitops): say what the policies actually did

The projection's claims are the part an operator acts on, and three of them were
wrong in the direction that makes a working feature look broken.

**The default policy reported itself as declining.** Every placement on the
operator policy recorded a `policy_is_operator` refusal, and the projection read a
recorded reason as "declined by policy, waiting on an operator". That policy never
runs, so it has no decision to decline with, and the default application on the
default policy said the system was withholding something. The refusal is no longer
recorded for that reason, and the projection's existing distinction between a
policy that declined and one that has not run now has something to distinguish.

**Source acceptance was always reported as an operator decision**, because the
authority was hardcoded rather than read from the approval. An operator on the
automatic policy was told they had personally accepted a revision nobody showed
them. It is derived now, from the same field every other read uses.

**A generation predating the policy contract reported a frozen policy that never
existed.** The decoder reconstructed the fresh-install defaults for a null
snapshot, so a rollout that ran before these settings were introduced claimed it
had been decided under a policy that did not exist at the time. The module header
already said absence is absence; the decoder was doing the opposite.

**A demoted Blueprint had a live automatic decision and no control.** The
backend accepts the policy write for it, and the placement decision evaluates it,
but the control sat behind a target-mode gate, so an operator whose application
decided its own placement had no way to reach the setting from the interface. Both
Blueprint modes now reach the block, the Git lifecycle actions stay Git-only
because a demoted application has no Git generation to authorize a rollout of, and
the view test that asserted the old behavior is updated with the reason.

**A policy change wrote no before and after.** The application snapshot both
history rows were built from omitted the three policy columns, so the audit trail
recorded that a policy changed without saying which way, and that a recorded
refusal had been cleared without recording the policy that produced it. Both are
in the delta now, pinned on the history row.

**The placement writer did not check its own domain.** `rolloutAuthorized` refuses
a policy-authorized mint unless the rollout authorization policy actually permits
one; `placementApproved` had no equivalent, so a caller could reach the single
writer with a policy-authorized approval and a snapshot that happened to agree.
Agreement by accident is not authority, and the domain rule now cannot be
satisfied by a caller that simply passes a matching snapshot.

**The rollout default after the upgrade is now stated.** The one-time backfill
restores automatic authorization for the live Blueprint applications that already
had it, and everything created afterwards waits for an operator: a newly adopted
Blueprint, one converted back and forward, and any row written by an older binary
during a downgrade. That is a real behavior change for those applications and it
was only explained in a code comment. It is now written where an operator looks.

Also corrects a route docstring that claimed an exact per-target check while
describing the wider gate, and drops the control's dead permission conditionals,
which were unreachable because the control returns nothing without the grant.
One audit finding was checked and rejected: the approval snapshot decoder is used
by the end-to-end suite, so it stays.

* fix(gitops): read the generation in force, not the candidate

The bounded-automatic policy worked only between source acceptance and the first
rollout. Every placement after that read as an unknown workload and went to
operator review.

Promotion removes the applied directory and renames the candidate directory over
it, so a promoted generation has an applied directory and no candidate directory
at all. The placement decision resolved only the candidate, found nothing there,
and treated the absence as unknown evidence. The withdrawal guard already had the
right reader for this: it reads the applied copy first and falls back to the
candidate for a generation that is accepted but not yet promoted. That is the
generation a target would actually receive, so it is the one the policy decision
rests on, and it is now shared rather than duplicated.

The end-to-end fixture staged a candidate directory for every case, which is a
generation that has been accepted and not yet promoted. That is the narrowest
window in the lifecycle and the one an install spends least time in, so the suite
described a state that a working install is almost never in and the reader was
wrong for every generation that mattered. The fixture can now stage a generation
as promoted: content under the applied directory, nothing at the path its row
still names. Three cases cover it, including one where both directories exist and
the applied copy is the stateful one, because the applied copy is what a target
would receive and the unpromoted candidate is what no target is running.

**The strict compose reader missed a merge key below the service.** It checked for
`<<` only directly under a service, so `services: { <<: *shared }` merged whole
services in past the check, and `volumes: [{ <<: *vol }]` attached a mount the
volume walk never visits. Both read as stateless and would have been placed
automatically. A merge key is now looked for at any depth. An alias is not a
merge key and still answers, because the parser resolves it and refusing every
anchored document would hold ordinary compose files for no safety gain.

**The operator-policy regression test could not fail.** It inserted an application
with no intent and no candidate, so the call skipped before any decision ran and
its assertions on the refusal columns held whatever the guard did. The case that
does reach the decision now carries those assertions, and deleting the guard
fails it.

Also reconciles three doc comments left doubled by successive edits, and keeps the
`policy_is_operator` reason text with the reason it stays: a node on an earlier
build still records that reason, and this dashboard reads the reason from whichever
node owns the application, so during an upgrade the raw enum would otherwise reach
an operator.
2026-09-28 15:56:44 -04:00
Anso f0906f86d6 feat(editor): add Save & Pull Images to the compose editor (#1948)
* feat(editor): add Save & Pull Images to the compose editor

Image acquisition only happened inside deploy and update, so warming the
image cache before a maintenance window, or staging an update to apply
later, meant deploying first. The editor's save dropdown gains a third
action that saves the authored compose and env edits, acquires the stack's
registry-backed images, and stops. No up, no restart, no recreate, no build.

Backend: POST /api/stacks/:stackName/pull-images, gated on exact stack:edit
and exact stack:deploy, the same pairing Git source apply uses. The route
takes the per-stack operation lock as image_pull, so a pull cannot race a
deploy and a second pull reports "is already pulling images".
ComposeService.pullStackImages passes --ignore-buildable when the stack has
build-backed services, so those are skipped and returned by name rather
than built. The route reconciles nothing, so it opens no recovery
generation, starts no health gate, writes no drift baseline or GitOps
generation, runs no policy gate, and records no activity entry: there is
nothing to roll back to, and the update-guard marker must not move.

Frontend: the item is gated on both permissions on desktop and phone, and
honors the diff-preview setting like its siblings. The phone split-button
gains a chevron menu rather than a third button, which would truncate Save
& Deploy at 390px. A pull that fails after the save succeeded reports only
the pull half, since the save already confirmed itself.

The advanced lifecycle options are not part of this change.

* fix(stacks): sanitize the image pull failure log entry

The pull route's catch block passed the caught error object to the console
unsanitized. A failed pull rejects with the whole compose output, so compose
or registry text could carry line breaks into the server log and forge
entries. Route it through the same sanitizer the stack name already uses,
matching the sibling route catches, and replace line breaks with a separator
first so a multi-service failure stays readable instead of welding together.

No change to the HTTP response: the 500 body still reports the same message.

* fix(editor): condense the image pull failure toast to compose's error lines

A failed pull answers with the whole compose output, layer progress
included, and the error toast quoted all of it. With Deploy Progress off
the toast is the only surface the operator sees, so the cause was buried.

The toast now keeps the last three distinct failure lines compose writes
("<service> Error ..." or "Error ..."), counts any it drops, and falls back
to the last output line when none match. Progress lines that merely contain
the word, such as a service named error-reporter, no longer count as
failures. The progress panel still streams the full output.

* fix(editor): deliver registry credentials, run preflight, and report skipped actions

A pre-merge audit of the Save & Pull Images feature found three defects. All
three are fixed here, plus two non-blocking findings folded in at review time.

Remote nodes were not receiving the main instance's registry credentials for the
new pull route. Two classifiers gate credential delivery independently and both
omitted it: the op classifier, which is the outer gate the proxy consults, had no
pull-images branch at all, so the delivery block never ran. Added a
stack-pull-images stage carrying the stack name from the path, since the route
sends no body, and added the route to the body-budget classifier.

The pull skipped the SOPS and required-env preflight every sibling compose
operation runs, so on a SOPS stack ${VAR} in an image reference would resolve
against still-encrypted files and could fetch an image a deploy would not use.
Both guards now run at the top, and the refusal names the action the operator
chose rather than telling them to deploy.

A pull skipped after a successful save said nothing, leaving one green save
toast as the last word. Both skip paths now report, and only after a save: a
bare call is either blocked by an affordance the UI already disables, or is a
second click while the first action is still starting, where an error toast
would be wrong. Save & Deploy reports the same way, which it previously did not.
The save row and the pull item also disable while a pull is in flight, on
desktop and on the phone, since a pull holds the same per-stack lock a deploy
does.

--ignore-buildable is now passed unconditionally, so a service that builds from
source is skipped whether or not the stack's build declarations were readable.
The skip list remains the report, and a build: declared only in an override file
is skipped but not named.

The op classifier and the body classifier disagreed on the trailing-slash form,
which Express routes to the same handler, so a slashed request was forwarded
with no credentials and failed late with an opaque unauthorized. Both now accept
it, and the rollback discover branch moves with them so a slashed rollback is
still discovered against the restore candidate.
2026-09-27 19:24:45 -04:00
Anso 324dd7e1ae fix(gitops): restore only the generation a Blueprint target acknowledged (#2024)
* fix(gitops): restore only the generation a target acknowledged

A Blueprint's Enforce mode restores state; it does not authorize it. The
repair path resolved the identity to restore from the application's
current pointers, so accepting a newer generation for any reason silently
changed what an already-deployed target was repaired to. On a target
acknowledged against an older generation this shipped compose content
that no rollout had authorized for it, pinned to an artifact set from a
different generation, which is a content and artifact substitution
arriving through the one path that is supposed to be safe.

The authority for a repair is now resolved in one place from immutable
rows: the target's acknowledged generation and the artifact set that
generation was qualified under, corroborated for Git-managed content by
the live rollout generation that froze the pair. The application's newest
accepted generation is no longer read on the repair path at all.

Every way the authority can fail to resolve becomes an explicit hold with
its own reason rather than a guess or a generic failure: no acknowledged
generation, a superseded rollout, a node outside the frozen target set, an
unreadable row, or a disagreement between the rollout and the target's
acknowledgements. A hold records its own deployment status and history
stage, repeats no work on the next tick, and never mutates the workload.

The on-node marker now names the generation, artifact set, and rollout that
installed it. Without that a repair could not prove what it was about to
overwrite, so a marker that does not name one holds rather than assuming.
Markers written before this still parse.

Tests cover the acknowledged generation winning over a newer accepted one,
the superseded-rollout hold, the never-acknowledged hold, and digest pins
coming from the acknowledged set.

* fix(gitops): hold every runtime repair the policy must not attempt

A drift repair that cannot prove what it would overwrite must decline
visibly, not fail quietly and not guess. Each way the authority can fail to
resolve now becomes a typed hold with its own deployment status, its own
history stage, and its own attention reason, so it stays visible instead of
reading as converged.

The arms are the states the drift model already treats as uncertainty rather
than divergence: a target that is unreachable or whose connectivity evidence
is stale, a target whose earlier mutation was interrupted, a target an
unfinished rollback or an unavailable LKG owns, and a target outside the
frozen required set of the rollout it is bound to.

Two decisions move ahead of the drift write rather than after it. A stateful
or unclassifiable Blueprint is never auto-repaired, and the classification
check now runs before the drift is committed, so a held target is not first
written as drifted and then rewritten on every tick. The check is repeated at
the site that mutates, so the guard holds even when that path is reached
directly.

A hold is a state, not a latch: a later tick re-checks the target, so clearing
the cause lets the next tick repair it. A hold that has not changed is not
recorded again, which keeps a permanently held target from appending history
forever.

The on-node marker gains the generation, artifact set, and rollout that
installed it, and the rollout path stamps them, so a drift check on a remote
leaf compares against the same authority the hub resolved rather than
inferring it. Both transports serialise the same marker, asserted byte for
byte in the tests.

Tests cover each hold arm, the hold-then-repair transition, replay
suppression, survival across a store reset, local and remote reaching the
same decision, and Observe and Suggest never mutating.

* fix(gitops): re-check held drift targets and report a hold as a hold

The hold work that closed the runtime drift policy made two states one-way,
which is worse than the behaviour it replaced. A repair that re-resolved its
authority and declined was reported as a correction failure and left the row
reading "correcting" with nothing in flight, and a held row matched no branch
in the decision pass, so an ordinary network flap could strand a target
permanently where it had previously self-healed.

Both paths now handle a hold as a decision: a declined repair records the hold
with its own reason and warning, and a held row keeps its drift check, so
clearing the cause lets the next tick repair it. A check that finds the target
already matching returns the row to active through a cause that records no
history, because nothing was written to a node.

Classification holds get their own reason instead of borrowing
evidence_incomplete, which sent operators looking for a missing generation
when the Blueprint was stateful. Their text drops the drift reason, which was
the replay key: a reason changing per tick re-alerted every tick for a hold
that never changed. The recovery arm now also covers an in-progress recovery
and a rollout awaiting its health verdict, both of which own the target.

Docs no longer promise every hold self-clears. Superseding is one-way, so that
hold persists until a later rollout covers the target or an operator deploys.

* fix(gitops): bound the recovery hold to a recovery in progress

The recovery arm tested recovery_phase for non-null, but that column keeps a
terminal value for the life of the target and is never reset, so every target
that had ever been rolled back would have been held from repair permanently,
with only an explicit deploy to clear it. Only capturing, restoring, and
compensating own the target now, matching the in-progress set the projection
already uses.

Clearing a hold without repairing it now advances the observation stage rather
than only the deployment row. A row that went back to active while the
projection still read the hold, or the drift, that had just cleared left the
Drift surface and the attention queue contradicting the deployment table with
no event left to correct them.

Both carry tests that fail without the fix. The projection case asserts a
literal status and requires the target to exist, because the optional chain it
replaced made the whole case pass on a missing target.

* fix(gitops): let a failed recovery keep holding, and rank both new statuses

A failed recovery was the one terminal phase that stopped holding once the arm
was narrowed to in-progress phases, so auto-repair would have performed exactly
the mutation an explicit deploy is already refused on the same target. Only a
recovery that finished successfully releases the target now, and a test pins it.

The severity ladder had neither of the new runtime statuses, and a status it
does not know does not degrade to something reasonable: it reads as
unrecognized and the whole application row becomes unknown. A hold is ranked
alongside drift for that reason.

Clearing a hold advances the observation stage rather than only the deployment
row, so the decision the reconciler made is what the projection reads, instead
of depending on the observed-artifact write that happens to follow it. The test
asserts the stage, because the derived status is identical either way.

A new "converged" runtime status was tried here and reverted: the posture
vocabulary has no slot for "checked but no health verdict", so it landed on
unknown. A matched drift check projects to the status a Blueprint target
already reached by the pointer path, which claims no health verdict either.

Each test was checked to fail with its fix reverted. Two earlier attempts at
the same tests passed with the bug reintroduced, one through an optional chain
and one because a neighbouring check claimed the status first, so both now
assert the mechanism rather than the outcome.

* test(blueprints): render a held repair in the deployment table

The held row is where a declined repair becomes visible, so it is rendered and
asserted rather than only typechecked. A status that reaches the table without a
label renders as a blank chip, and only the label map being total over the
status union would have caught it.

* fix(gitops): classify drift for Inline targets and observe held ones

The repair authority was corroborated against a rollout generation whenever
the target carried a rollout_generation_id, but an Inline target can carry one
that was never the authority for its acknowledged pair, because Inline freezes
its own generation per acceptance. Every such target was held for a disagreement
that could not exist, and a held target reported no drift at all, so a replaced
image on an Inline Blueprint was invisible in every drift surface. The
corroboration is now keyed on the content mode rather than the pointer.

A hold also suppressed the observation, because it returned before the runtime
identity was collected. A hold refuses to mutate, not to observe, and what the
node is running is exactly the evidence an operator needs when a hold blocks
the repair. The identity is now captured before the hold is returned, with the
probe and the record each guarded so neither can turn the hold into a failure.

Both carry tests verified to fail with the fix reverted.
2026-09-27 18:49:20 -04:00
Anso 0ab26529d3 feat(dashboard): rebalance the Home lower half around operational signals (#1953)
* feat(dashboard): rebalance the Home lower half around operational signals

Home now reads as a triage: Recent alerts leads the operational row, with
Fleet heartbeat beside it on multi-node installs, and Configuration summary
closes the page. Every card below the gauges renders through one shared
panel shell and the shared Table primitives; actionable rows stay real
table rows with a native button in the primary cell.

Recent alerts gains a This node / All nodes scope that follows the active
node, a fixed eight-row preview with View all alerts, deterministic
drill-downs (stack, drift tab, container logs, scan history, Fleet updates)
that switch nodes race-safely, and first-class feed gaps so a silent node
never reads as an all-clear. Fleet heartbeat takes the alerts card's
height, orders problem nodes first, separates offline from unknown, and
opens node details from any node. Configuration summary collapses to one
row per domain with a per-setting Node / Account / Instance scope,
reachability-gated drill-downs, stale handling, and fresher invalidation.

The Stack restarts card is removed from Home, along with the
stack-restarts endpoint and query it was the only consumer of.

* test(e2e): scope the node-switch locator to the switcher popover

* docs(dashboard): document the operational Home layout
2026-09-24 03:16:43 +00:00
Anso a881e425f8 feat(gitops): compose the authority evidence into the Blueprint rollout preview (#1957)
The Blueprint rollout preview now embeds the canonical GitOps revision
projection and binds the confirmation to a digest of the authority evidence
it displays: the recorded approval refs, the rollout generation, and the
source, artifact, placement and rollout facet identities. Apply refuses with
PREVIEW_STALE unless the intent fingerprint and the evidence digest both
still match, so an acceptance, artifact identity, placement approval,
preflight result or rollout authorization that moved under the preview
cannot execute.

The dialog renders the composed evidence through the same chips, cards and
per-target rendering the GitOps application view uses, keeps the legacy
combined approval separate from the decomposed steps, and refreshes or
closes on a stale refusal instead of re-arming a preview the server already
rejected. Volatile observations (timestamps, retry counters, per-target
runtime and health readings) are excluded from the digest so a steady-state
application does not read as permanently stale.
2026-09-23 03:11:28 +00:00
Anso 489e1a9a9a feat(blueprints): detect and enforce runtime digest drift (#1949)
* feat(blueprints): detect and enforce runtime digest drift

Compare frozen expected artifact identity to live container digests so
tag movement surfaces as drift, and Enforce repairs with digest pins
(--pull never) without rewriting authored compose. Unreachable or
incomplete observation stays unverified and never auto-corrects.

* fix(security): keep digest overlay in tmpdir and sanitize observation logs

Write Compose digest-pin overlays under os.tmpdir() instead of the stack
directory so path expressions are not stack-name tainted, and log runtime
observation failures with a fixed format string plus sanitized stack name.

* fix(security): create digest overlay via exclusive mkdtemp

Avoid CodeQL insecure-temporary-file by writing the Compose digest-pin
overlay into an exclusive mkdtemp directory with wx create, then removing
the whole directory after deploy.

* fix: compare Blueprint digest drift by per-target platform identity

Enforce now routes container repair separately from digest pins, keeps
compose.override.yml on overlay deploys, and fails closed when the
target platform is unknown.

* test: expect platformVariants on single-platform digest resolve

* test: drop unused seedApp binding in digest-repair suite

* fix: drop redundant platformLabel nullish coalesce after guard

* fix(blueprints): accept override-only services in apply-local pin guard

* fix(blueprints): freeze and repair remote digests from the leaf

Hub digest freeze and Enforce repair now fetch the target node's rendered
Compose model and Docker platform over the authenticated proxy, so a remote
compose_dir string is never treated as a hub-local path. apply-local pin
validation runs after write against the rendered multi-file model.
2026-09-20 20:26:45 -04:00
Anso c02dfa4fdd feat: listen with HTTPS so Pilot credential delivery stays confidential (#1943)
* feat: listen with HTTPS so Pilot credential delivery stays confidential

When the control instance is not behind a reverse proxy, cert and key files
opt the process into an HTTPS listener so Pilot tunnels are encrypted. Enrollment
emits an https URL and an optional CA bind-mount. Pilot agents ignore those
cert files so loopback replay stays HTTP.

* fix: verify native TLS health probes against the public URL

Keep certificate verification on for the loopback HEALTHCHECK. Dial 127.0.0.1, check identity against SENCHO_PUBLIC_URL, and trust SENCHO_TLS_CA_FILE when set.

* fix: trust HEALTHCHECK CA via the process store, not the request

Private-issuer PEM is added with tls.setDefaultCACertificates so the loopback HTTPS probe never puts file bytes into https.get options.
2026-09-19 13:51:27 -04:00
Anso e62b059abf feat(gitops): native Git provider webhook ingestion (#1934)
* feat(gitops): native Git provider webhook ingestion

Add hub-facing provider hook ingest with per-stack endpoint CRUD, signature
verification for major Git hosts, and GitOps reconcile via provider_event.

* fix(frontend): drop unused isDarkMode prop from provider hooks card

* fix(frontend): stop destructuring unused isDarkMode in GitSourcePanel

* fix(gitops): log remote hook forwards and tighten provider ingest

Record GitHub-family event actions, skip noisy PR sub-actions, treat
replayed deliveries as duplicates without a second reconcile, and drop
the GitLab HMAC header claim that the real provider does not send.

* fix(gitops): retry failed provider deliveries on redelivery

Do not treat processing_failed as a settled ingest state, so a host redelivery can run reconcile again. Deduplicate the delivery lookup helpers.

* fix(gitops): align provider delivery headers and drop unused rate_limited

Use native GitLab/Gitea/Forgejo delivery-id headers in adapters and ingest,
assert stored delivery ids, and remove the unreachable rate_limited state.
2026-09-18 13:01:20 -04:00
Anso ea905b758b feat(gitops): bind Blueprint content to a live Git source (#1926)
* feat(db): bind Blueprint content origin to a GitOps application

Pair Git-managed Blueprints one-to-one with an application at the schema
layer, and retain the original Direct stack identity so conversion can
reuse credentials without minting a second live application.

* feat(gitops): add guarded Blueprint binding transitions

Reconfigure a live Direct application in place to Blueprint mode through
a replay-safe store update, keeping identity and generation lineage
stable while recording the mode change in history.

* feat(gitops): convert Blueprints onto a live GitOps application

Move an existing Direct application in place, freeze Inline YAML as a
detach snapshot, and refuse compose edits and deletes while content is
Git-managed. Previews expose non-secret source fields and rollback
limitations only.

* fix(blueprints): refuse Git-managed deploy, apply, and delete

Skip reconciler and service apply paths, return 409 before withdraw
or approval, and mint a blocked rollout candidate so a converted
application stays visible as not executable.

* feat(gitops): keep converted applications on the source controller

Widen poll and retry scans to converted Blueprint sources, route accepted
generations from the live target mode, and refuse Git-tab mutations while
a Blueprint still holds the source stack.

* feat(api): add Blueprint GitOps content-binding endpoints

Expose convert, detach, and adopt so a Blueprint can bind a live Direct
application without leaking retained source-stack identity.

* feat(ui): add Git-managed Blueprint editor and binding dialogs

Operators can convert, adopt, and detach Blueprint content onto a live
Git source. The editor and Git tab stay read-only while rollout is blocked.

* chore: document Git-managed Blueprint content binding

Operator docs and What's New cover convert, detach, and the blocked
rollout state. Marker parse keeps optional binding evidence, and
Git-managed dispatch uses the operator-facing blocked reason.

* test: type BlueprintEditor submit mocks without unused params

* fix(gitops): close Blueprint binding audit gaps before QA

Expose Retire routes and UI, return 409 on Git-managed blueprint PUT edits, and render binding preview markers and node actions in convert, adopt, detach, and retire dialogs.

* fix(gitops): make claimed sheet, adopt, and retire paths production-safe

Project blueprint-claimed sources on git-source GET. Adopt tombstones inline apps
like convert. Retire restores the original stack identity and clears evidence.

* chore(gitops): close non-blocking binding follow-ups

Refresh stale projection comments, document Retire, include converted
sources in polling settings, and remove unused blueprintModePromoted.

* docs: mention Retire in Blueprint intro copy

Keep the overview consistent with Detach-or-Retire exit paths.
2026-09-17 22:02:19 -04:00
Anso 71595cb1ce feat(gitops): source controller follow-on (#1920)
* feat(gitops): revalidate candidates append-only and persist a settled outbox

Record the candidate digest and source-policy evidence at insert. Auto-accept remints a new generation when evidence changed, refuses missing hashes and fingerprint drift, and never updates a generation row. Settled history writes a versioned outbox so notification fanout survives a crash, with unique dedupe and unknown versions left undrained.

* feat(gitops): convert Git source to a SystemSheet and advertise controller controls

Move the Git source panel to the canonical right-side sheet, add suspend, resume, retry, and node polling, and register gitops-source-controller on both sides so the controls stay hidden until the node advertises them.

* fix(gitops): keep candidate compose reads inside the generation directory

Resolve each compose path against the candidate dir and refuse when it
escapes, both when recording the content digest and when revalidating
before auto-accept.

* fix(gitops): close audit gaps on digest reuse, suspend pull, and polling load

Pre-upgrade candidates without a content digest now mint anew instead of
reusing forever, Pull now hides while suspended with matching copy, and
Git polling skips GET for users without node:manage.
2026-09-16 18:19:28 -04:00
Anso 30e39368ab fix: preserve exact guardrail message for Save & Deploy and Start (#1898)
* feat: preserve exact guardrail message for Save & Deploy and Start

When 'Block deploy on missing required env vars' is enabled, the exact
guardrail message now reaches Save & Deploy and Start (not just Update).
Previously the external-networks preflight replaced the message with a
generic networking error. The fix selects the guardrail-aware message
inside the existing render pipeline without duplicating the render.

Changes:
- Backend: isGuardrailEnabled() plus guardrail-aware message selection in
  renderModel(), with exact singular/plural grammar matching the
  ComposeService guard method.
- Frontend: parseMissingExternalNetworksPayload validates non-empty
  renderError; missingExternalBlocksDeploy prefers it with generic
  fallback; MissingExternalNetworksPayload extended with optional field.
- Tests: backend regression for plural grammar; frontend regression for
  guardrail-on/off, older-node fallback, empty-string fallback, and no
  deploy-POST assertions.
- Documentation: environment-guardrails tutorial updated.

* fix: log guardrail-setting read failures and fix guardrail test isolation

isGuardrailEnabled() dropped its nodeId on read failure instead of
logging it, unlike the sibling isAutoCreateEnabled() next to it.

The new guardrail-message tests never enabled the guided external-network
preflight flag they were meant to exercise, and never reset the shared
apiFetch mock, so their assertions were checked against leftover calls
from unrelated earlier tests in the file.
2026-09-15 22:43:50 -04:00
Anso b6766572df feat(navigation): make Compact Launcher the default desktop style (#1890)
* feat(navigation): make Compact Launcher the default desktop style

Compact Launcher replaces Smart bar as the default desktop navigation
style; Classic bar is fully retired (rendering, settings option, and
persisted-value migration to Compact). Quick-link capacity increases
from 7 to 8, and the recommended default set becomes Home, Fleet,
Resources, Security, Update, and Schedules.

Quick-link seeding and Reset now use a settled, role-aware eligibility
computation distinct from the existing current-context display
filtering, so an operator's recommended defaults reflect their role
rather than which node happens to be active, and a Reset produces the
same result regardless of node context.

Also fixes two issues surfaced while touching this code: a disabled
quick-link control showing a duplicate tooltip (native title plus the
Radix tooltip), and the Navigate launcher panel being unable to scroll
at constrained viewport heights (now uses the shared ScrollArea
component). The launcher hamburger icon animates into an X on open,
respecting Reduced motion.

* test(e2e): fix the launcher morph and panel scroll navigation assertions

Three assertions in the new navigation specs were wrong against a real
browser, all in the tests rather than the product:

- The morph check read getComputedStyle().transform, which Tailwind v4
  no longer writes: rotate-45 compiles to `rotate:45deg` and the
  translate utilities to the standalone `translate` property, so both
  the open and closed reads returned "none". It now snapshots translate
  and rotate alongside transform. The animation itself was always
  correct, since Tailwind's transition-transform covers translate,
  scale, and rotate.
- The reduced-motion check assumed motion starts enabled. A fresh
  install defaults to the Calm visual style, which turns Reduced motion
  on, so the clamp was already active and the pre-toggle duration
  assertion could never hold. It now drives the toggle in both
  directions from a known state.
- The panel scroll check asserted a specific overflow measurement,
  which depends on Radix having applied its available-height variable
  at read time and on how many destinations the account can reach. It
  now asserts the property the fix actually guarantees: the ScrollArea
  viewport owns vertical scrolling while the outer menu only clips,
  with no horizontal overflow and the panel inside the viewport.

Verified by running the spec against live dev servers: 7 passed, twice.

* fix(nav): make the Navigate launcher panel actually scroll with the mouse

Live QA found that the panel only reached destinations below the fold
by keyboard; a real mouse wheel did nothing. The Radix ScrollArea
viewport is sized with h-full, and a percentage height only resolves
against a containing block with a definite height. The popper content
is height:auto clamped by max-height, which is not definite, so
nothing sized from it is either, so the viewport fell back to its full
content height with no internal overflow, no scrollbar, and no
response to wheel input, while an ancestor's overflow-hidden silently
clipped everything past the fold.

Moves the available-height cap onto the viewport itself and moves the
masthead inside the scroll region so the cap needs no masthead-height
arithmetic. Verified live: the previous structure measured
scrollHeight === clientHeight === 594 with zero wheel movement; the
fix measures 646/357 with the wheel reaching the true maximum.

Also hardens the panel's e2e coverage, which passed on the broken
structure because it asserted only computed overflow properties and
never drove a real scroll: adds a keyboard-reach assertion using End
rather than ArrowDown (ArrowDown landing on the first item is stock
roving focus and proves nothing about scrolling), a genuine
mouse-wheel assertion that scrolls to the true bottom rather than
assuming one gesture covers the range, and a companion test proving
the cap tracks the popper's available height rather than a hardcoded
pixel value. Confirmed the rewritten test fails on the previous
structure and passes on the fix.
2026-09-05 20:03:48 -04:00
Anso 48f010475b feat(gitops): resolve branch, tag, and SHA refs to immutable commits before fetch (#1864)
* feat(gitops): resolve branch, tag, and SHA refs to immutable commits before fetch

The ref model now resolves a configured branch, tag, or full commit SHA to
an immutable commit before any content is downloaded, and records both the
configured and the resolved identity where revision state persists.

- RefKind (branch | tag | sha) is a resolved property, not caller-asserted.
  A bare string resolves branch-first, then tag; a full 40/64-hex SHA
  self-resolves with no remote round-trip. Branch and tag both fetch via a
  bare --branch name; a SHA uses init + shallow fetch + detached checkout.
- A single ls-remote with narrow heads/tags refspecs pins the configured ref
  to an immutable SHA; rev-parse HEAD must equal the resolved SHA or the
  fetch refuses (tip-changed) instead of materializing unreviewed content.
- Error union grew: REF_NOT_FOUND (ref-neutral, replaces BRANCH_NOT_FOUND),
  UNSUPPORTED_REF (a pinned SHA the host will not serve), and a service-level
  REF_DELETED upgrade that fires when a classified REF_NOT_FOUND occurs for a
  source with prior fetch history (a vanished ref reads as delete/force-push,
  not a fresh typo). Status mapping: REF_NOT_FOUND/REF_DELETED to 404,
  UNSUPPORTED_REF to 400.
- Configured-vs-resolved identity is recorded via a nullable resolved_ref_kind
  column on gitops_generations (added to CREATE TABLE and re-added for legacy
  installs through maybeAddCol). The kind is deliberately NOT in the plan
  fingerprint: two sources naming the same commit differently are the same plan.

Docs updated (git-sources feature page, connect-a-git-source tutorial, and the
native-git-transport internal deep-dive) to the ref-neutral naming.

* fix(gitops): harden ref resolution after pre-merge audit

Request peeled annotated-tag refs from ls-remote, detect force-pushes and
ref-kind changes against prior fetch identity, persist resolved kind on
application rows, and add real-git tag/SHA integration coverage plus
ref-neutral UI and operator docs.

* test(gitops): mock verifyFastForward in direct producer suite

The producer tests stub the transport seam but were missing resolved kind
on resolveRef and a verifyFastForward stub, so second pulls tripped the new
ref-continuity checks as REF_DELETED.

* test(git): remove unused buildBareFixtureRepo helper

Fixes backend lint failure after the integration fixture was refactored
to buildRichFixtureRepo without dropping the old wrapper.

* fix(gitops): correct fast-forward ancestry verification under size bounds

Replace the dual shallow-fetch ancestry probe with a single-tip deepen
strategy, keep verifier Git work inside the transport watchdog, and add
real-Git regression coverage for linear advances and rewritten history.

* fix(gitops): bound fast-forward verification with exponential deepen

Replace per-commit deepen loops with exponential steps, cap remote fetch
rounds, and share one deadline across verifier Git calls. Budget exhaustion
now surfaces as a classified timeout instead of REF_DELETED.

* fix(gitops): classify fast-forward probe failures accurately

Normalize verifier probe timeouts and unexpected exit codes into transport
failures, interpret merge-base status 1 as proven non-ancestry only, and
treat shallow stagnation as timeout instead of REF_DELETED.

* fix(gitops): satisfy tsc on probeFailure never returns

* fix(gitops): address Phase E QA findings on ref verification

Remove the fast-forward scratch repo after verification so pull size
caps are not inflated, classify GitHub not-our-ref as UNSUPPORTED_REF,
persist fetched_resolved_ref_kind on create-from-git, and broaden
REF_DELETED copy for retagged tags.
2026-08-28 16:15:37 -04:00
Anso 7cd42699d1 fix(sso): preserve admin-assigned roles across SSO sign-in (#1862)
* fix(sso): preserve admin-assigned roles across SSO sign-in

An SSO/OIDC/LDAP user's role was overwritten by the IdP-derived role on
every sign-in, so a role an admin assigned in Settings > Users reverted to
the provider default on the next login. Gate role re-sync behind an opt-in
sso_role_sync setting (default off), so manual role edits persist unless the
operator explicitly enables IdP-authoritative sync. Email continues to sync
unconditionally.

Adds human-session-only GET/PUT /api/sso/config/role-sync endpoints with a
hub-side API-token rejection in the remote proxy, a frontend toggle, a
regenerated SSO settings screenshot, and matching docs.

Closes #1851

* fix(sso): satisfy CodeQL on role-sync log and test token hashing

Route three inline API-token creation blocks through the shared
createTestApiToken helper so the sha256 hashing lives in one place, and
log the role-sync toggle as a word instead of a raw boolean. No behavior
change; resolves the CodeQL js/insecure-hashing and log-injection alerts.

* fix(sso): harden role-sync gate, name the toggle, fix screenshot

Addresses pre-merge review findings on the SSO role-sync feature:
- Make the hub-side SSO config authz guard case-insensitive to match
  Express routing semantics, closing a case-variant API-token bypass.
- Give the IdP role-sync switch an accessible name.
- Capture the SSO settings screenshot at desktop size with the scroll
  area expanded so the role-sync control is fully visible.
2026-08-28 09:26:22 -04:00
Anso 38ee4527b1 feat(git): add GitOps revision store and Direct apply transitions (#1835)
* feat(git): add GitOps revision store and Direct apply transitions

Adds the canonical GitOps revision state model: schema, store, history,
approval resolution, Direct apply transitions, and a first-cut read
projection. Also widens recovery capture with generation, artifact, and
source-acceptance bindings, rejects credentials and query strings in new
Git repository URLs, and keeps Blueprint and node-label routes on the hub.

* feat(git): add create-from-Git activation and teardown transitions

Adds the durable half of create-from-Git: the single transaction that persists
the application, resolved commit, generation, checkpoint, and candidate
together, and the teardown that tombstones a create which never reached its
success boundary. Adds the staging marker and operation-owned cleanup rules
that decide what a crashed create is allowed to delete.

* feat(git): make create-from-Git crash-safe

Wires the create path through the GitOps state model: the staging marker is
written before the candidate is built, the activation transaction persists the
application, generation, and checkpoint together, and the source row plus
accepted pointers now commit as one success boundary. A create that fails
before that boundary removes only what it staged, then tombstones itself.

The managed-area sweep no longer deletes a directory it cannot prove it owns,
counts in-flight creates as claimed, and is awaited at startup so an
interrupted create is settled before any mutation service runs.

* feat(git): settle interrupted create-from-Git at startup

Adds boot-time recovery for creates a previous process left in flight. A create
whose project was already committed to disk is finished, including its source
row and accepted pointers. Anything earlier has its files removed first and is
then tombstoned, so the stack name becomes usable again instead of staying
locked by a half-created application. A source row that outlived its
application is always preserved.

* fix(git): close create-from-Git ownership and reporting gaps

Removes two ways a failed create could destroy files it did not own: boot
recovery no longer deletes a stack directory the create never recorded making,
and the managed-root teardown is gated on having created that root. Cleanup
now rejects a path that is not a generation directory, so a malformed row
cannot widen the blast radius.

A create that fails during materialization now clears its staging marker, which
previously stayed behind and made the stack name uncreatable until restart.
Generation paths no longer resolve through a module cycle that left them
pointing at a directory that never existed.

Boot recovery restores the deploy spec and manifest cache a finished create
needs, settles each application independently so one bad row cannot strand the
others, and distinguishes a missing directory from one it could not read. A
create that succeeds now clears its checkpoint, and a failure after the success
boundary says the stack was created rather than reporting a bare error.

* fix(git): reap managed areas that nothing has ever claimed

The boot sweep now distinguishes a missing staging marker from an unreadable
one. A missing marker means no stack, no create, and no marker claims the
directory, which is the ordinary orphan a crashed stack deletion leaves behind,
so it is removed as it always was. An unreadable marker is evidence of a claim
that cannot be verified, so the area is preserved.

Without this an orphaned managed area survived every boot forever, since a
completed create deletes its marker and nothing else would ever claim it.

* feat(git): complete the Direct source and target transitions

Adds the remaining Direct events the state model owed: an invalid fetch that
advances the resolved commit without minting a candidate, a blocked candidate
that is visible but cannot apply, dismissal that leaves the running workload
alone, material configuration change that invalidates a staged candidate while
keeping accepted and applied pointers, the two deploy failure classes, and
application and target tombstones.

Widens the application update to every mutable column. It previously wrote
about half of them, so a transition assigning a pause, suspend, recovery, or
intent field would have type-checked, appeared in the history snapshot, and
been silently dropped at commit.

* feat(git): record Direct Git operations in the revision state

Wires fetch, apply, and detach through the state model. A pull records the
fetch and, when it produces a candidate, the generation behind it, marking a
blocked change plan as a blocker that cannot apply. An apply records the
acceptance that binds the generation to the workload, and closes its operation
on failure so a throw cannot leave the source reporting work in progress.
Detach tombstones the application and its target in the same transaction that
removes the source row.

Every producer is a no-op for a stack with no live application, so installs
whose Git stacks predate this model are untouched until migration runs.

* test(git): cover the Direct Git producers end to end

Adds a harness that stubs only the git transport and rollback capture, so
fetch, candidate materialization, change-plan classification, apply, and detach
all run for real and are asserted against the resulting revision state.

Covers the seam the transition-level tests could not: that a fetch advances the
resolved commit without moving the accepted generation, that the apply binds
the exact candidate the fetch recorded and its acceptance proves that
generation and no other, that detach tombstones while keeping configured
identity as a frozen fact, that a failed fetch closes its operation and leaves
every pointer alone, and that a stack with no application produces no writes.

* feat(git): bind the deployed generation from the Compose adapter

Makes ComposeService the sole producer of deploy events. Every deploy path
funnels through deployStack, so recording it there keeps one start and one
terminal row per mutation instead of each caller reporting its own.

A successful deploy binds the applied generation, a failure records the class
and leaves the deployed pointer alone. The class is conservative: once the
compose command is handed off we cannot prove the workload was untouched, and
claiming it was intact is the more dangerous error. A stack with no live
application, or one with nothing applied, has no deploy identity to record and
is skipped entirely.

* feat(git): promote healthy and last-known-good from health verdicts

A stack health run now records which generation it observed, and its verdict
drives the revision state. Promotion is narrow by design: healthy and
last-known-good move only when the run passed, watched the whole stack, and the
generation it watched is still deployed. A stale or service-scoped verdict
records history and moves nothing.

Last-known-good keeps the artifact expectation only when that expectation
belongs to the promoted generation. Otherwise the generation is still good and
its executable identity simply is not proven, so the pointer is left null
rather than borrowed from another generation.

beginStack now takes the deployed generation explicitly rather than reading
current state, because a verdict is only meaningful for the generation the run
actually observed. Update-path callers pass null until updateStack reports what
it deployed.

* feat(git): bind the deployed generation on the update path

Gives updateStack the same treatment deployStack has: it opens a deploy
operation at the point Compose is handed the mutation, closes it on both
outcomes, and reports the generation it bound. The orchestrator carries that
binding through, and all five update call sites now pass it to the health gate
instead of null, so a passed health run after an update promotes healthy and
last-known-good exactly as it does after a deploy.

The operation is opened at the compose call rather than at entry, so an update
that fails during capture or classification records nothing: it never touched
the workload, and reporting a deploy failure for it would make the projection
claim something that did not happen.

* feat(git): track linked stacks and material config changes

Linking an existing stack to a Git source now brings it into the revision
state: the application starts live with nothing desired or accepted, so the
projection asks for a fetch rather than claiming a state it has not observed.

Editing a source's material configuration invalidates its staged candidate in
the same transaction that writes the row and clears the pending pull. A
candidate built from a different repository, ref, or file set can no longer be
applied, and clearing pending without invalidating it would leave the model
offering an apply the operator cannot produce. Credential-only and policy-only
edits change nothing material and leave the candidate alone.

* feat(git): retire the application when a stack is deleted

Stack deletion now tombstones its GitOps application and targets in the same
transaction that commits the deletion. A deleted stack that kept a live
application would go on claiming the stack name and block re-creating it
through the unique live-application index.

The tombstone is driven from the deletion service rather than from inside
DatabaseService, so the store keeps its transitions and their history in one
place and the two modules do not form an import cycle.

* feat(git): retire node targets when a node is removed

Deleting a node now tombstones the GitOps targets that lived on it, in the same
transaction as the delete and while those rows still exist. Otherwise a
part-way failure would leave targets pointing at a node that is gone.

Applications stay live: a Direct application still describes a real stack, and
a Blueprint one may have targets on other nodes. Both the local and remote node
paths go through the same helper, so neither can skip the retirement.

* feat(git): add proof-bound recovery transitions

A restore moves a target back to an older generation, the one case where a
target and its application legitimately disagree about what is current. These
transitions decide what may move with it.

Pointers move only when the restore is provable: the recovery point named a
generation, that generation still exists, and it belongs to this application.
The artifact expectation comes from what the recovery point captured, never
from what the application expects now, and the acceptance is kept only if it
still proves the restored generation rather than borrowing one that authorized
a newer one. A last-known-good survives unless the generation behind it is gone
or turns out to belong elsewhere, and then the reason is recorded so the
projection can say unavailable rather than none.

An unproven restore is still recorded as a real operational recovery and moves
nothing, because there is no evidence to move pointers to.

* fix(git): close GitOps operations that cannot be recorded

Recording a transition still never fails the operation it describes, but a
rejected terminal event no longer leaves that operation open. A start that
never terminates reported work in flight for ever and offered no actions, and
because a new fetch refuses to open a second operation, one rejection silently
stopped the model tracking that stack until a restart.

Startup now reclassifies operations the previous process left open, which the
transition already supported but nothing called. An interrupted restore is
closed there too, since only the terminal recovery events clear it.

The pull records its fetch, generation, and candidate as one transaction, and
the apply verifies the candidate was built from the commit being applied.
Separately those two allowed an apply to accept a generation whose files were
never on disk while the projection reported the older commit as current.

Also: the candidate now records its own invocation rather than the one it
replaces, changing material configuration is refused while an operation is in
flight, a tombstoned target cannot be repopulated by a late health verdict, a
deploy handle is not returned when its start was not recorded, the mutation
handoff is marked only once Compose is genuinely about to receive it, and a
rejected tombstone fails one deletion rather than aborting the boot sweep.

* feat(git): record why unprovable evidence was dropped

The projection carried a limitations array that only read-time derivation ever
wrote to. When a transition cleared a pointer it could not prove, the result
was indistinguishable from that pointer never having existed, and in two cases
it made the target read healthier: a dropped artifact expectation silently
disables the runtime drift check, and an unproven restore left every pointer
agreeing with itself so the target reported as synced and healthy.

Adds a persisted, fail-closed evidence record on the application and target
rows. The transitions that drop an artifact expectation, a last-known-good
artifact, or a source acceptance now say why, an unproven restore is marked as
such, and the deriver folds all of it into the limitations the projection
already exposes. Clearing a code when the evidence becomes provable again is
part of the contract, so a stale doubt cannot outlive its cause.

This lands before the migration matrix because migration is the largest
producer of evidence that cannot be proven, and the plan requires those to
surface as bounded limitation evidence rather than as fabricated pointers.

* feat(git): migrate pre-existing Git stacks into the revision state

Git stacks created before this model had no application at all, so every
producer was a no-op for them and the projection could not describe them. They
are now brought in at boot.

The governing rule is that a canonical pointer is written only when the
evidence proves that exact generation under the repository and ref configured
now. A legacy applied commit is not that proof on its own: the manifest may be
gone, unreadable, or stamped for a repository the stack no longer points at. In
each of those the commit is kept as recorded limitation evidence and the
pointers stay null, so the stack asks for a fetch instead of asserting a state
nobody verified. Deployed, healthy, and last-known-good are never invented,
because a manifest proves what was materialized, not what is running, and no
source acceptance is written because nobody approved through the model.

Replay is decided by scope, schema version, and configuration fingerprint. A
changed fingerprint re-runs the matrix, but a stack that already has an
application is skipped rather than rebuilt, so migration cannot overwrite
pointers written with proof it does not have. A stack whose directory has
vanished migrates to a tombstone rather than claiming a name it cannot back.

* feat(git): record rollbacks in the revision state

A restore now opens a recovery in the model before any file moves, so a crash
mid-restore leaves a target that says what it was doing rather than one that
merely looks broken, and closes it on both outcomes. The failure is classified
by whether the files had already been restored, because only a failure before
that leaves the previous workload provably intact.

Pointers move only when the restore is provable: the recovery point named a
generation, that generation still exists, and the manifest actually restored
carries the same commit and manifest version. Anything less is still recorded
as a real recovery, it simply has nothing to bind, and the transition marks it
unproven rather than guessing.

The deployed pointer is never claimed here. This path drives Compose through a
callback that reports nothing back, so binding cannot be proven and applied
moves without it.

* feat(git): add the deferred rollout-state transitions

Retry scheduling, suspension, pause, and partial rollout complete the
transition store. They have no production writer by design, but implementing
them now means the deriver has no branch a writer cannot reach, and the shape a
future producer has to satisfy is pinned rather than inferred from the read
side.

None of them is a statement about health. A scheduled retry leaves the failure
that caused it visible, so a stack that keeps failing does not read as merely
busy. A suspended source keeps everything it had accepted, and an operation in
flight is interrupted rather than abandoned so it cannot report as running for
ever. A paused or partially rolled out target keeps whatever was deployed, and
the partial record never stands in for a deployed pointer.

* test(git): stop the Compose producer tests reading Docker from the host

Both deploy cases asserted that the compose command would reject, which was
only true on a workstation with no Docker daemon. On Linux the command
succeeded, the promise resolved, and the assertions failed.

The compose subprocess now reports an exit code each test chooses, so the
adapter is what decides the outcome rather than the machine. Only the verbs
that need a daemon and travel through spawn are answered; `config` still runs
for real, because a host with the CLI and no daemon parses compose files
exactly as CI does, and failing that call breaks every create in the file.

That also makes the bound path reachable, so the deploy test now covers it:
a failed compose leaves the deployed pointer alone and classifies
post-mutation, and a successful one binds the applied generation, returns it
for health to bind against, and clears the earlier failure.

* fix(git): contain every managed-area path at the call that uses it

A stack name reaches the managed root without passing through
`isValidStackName` on this path, so the marker, the cleanup, and the create's
root probe all built filesystem paths from unvalidated input. Each call now
resolves its target against the managed area and checks containment in its own
scope, which is also the form the security scan credits: it does not follow the
barrier through the shared `isPathWithinBase` helper, so the check has to sit
with the call it protects.

`cleanupUnclaimedManagedRoot` removes a whole root recursively and gets the
same check, even though it was not among the reported calls.

Clearing a staging marker can now fail, so the two boot-recovery branches that
dropped the checkpoint first were reordered. Losing the checkpoint while the
marker survives would report the area as settled and leave the stack name
uncreatable.

The deploy adapter's two failure logs built their format string from the stack
name; both now use a constant format with the name as an argument.

The marker and cleanup fixtures stood a bare temp directory in for a managed
root, so nothing exercised the invariant these checks enforce. Both now build a
real managed area over a scoped data directory.

* fix(git): refuse to claim a managed area whose marker cannot be read

A marker that exists but will not parse is still someone's claim, yet the write
path refused only a readable foreign marker and wrote straight over a corrupt
one, discarding the reason and logging nothing. A transient permission error, a
marker truncated by a crash between write and rename, or a full disk all read
as corrupt, and each one let a second create take ownership of an area the
first still owned. Every other path in the module preserves on corrupt; this
one now does too, and says why.

Clearing a settled create's staging marker can fail, and reporting that as a
retained create sent a reader looking for an unfinished create that had
finished. It reports `marker_retained` instead. The checkpoint is still kept
for the retry, so its encrypted token can outlive the create while the clear
keeps failing; that tradeoff is stated at the call, because the alternative
leaves a marker no later create can get past.

The reaper also computed why it was preserving an area and threw the reason
away, so a directory could survive every boot with nothing said about it.

The containment checks these paths rest on had no tests and were silently
deletable, so the out-of-area read, write, delete, and reap now have them.

* feat(git): bind and observe a recovered generation

A proven restore could never claim the deployed pointer, because the restore
path drives Compose through a callback that reported nothing back: a caller
that restored some other way resolved identically, so binding on a resolved
promise would have claimed a workload nobody launched. The Compose wrapper now
returns what it did, and only that answer binds.

With binding reachable, a bound recovery claims its health run inside the same
transaction as the pointers it describes, and arms the timer once that
transaction lands. Committing the two together is the point: a crash between
them would otherwise leave a restored workload nothing was watching. Anything
that stops the timer starting writes the run off immediately, so an observing
row never outlives the timer meant to watch it, and a reservation is never
armed across a restart.

The reservation is handed to the transition rather than reached for, because
the health gate reports its verdicts back through the transition store and
importing it there would close a module cycle.

Startup now finalizes each interrupted observation on its own instead of
sweeping them with one update, so the revision state hears a verdict for every
run rather than watching the rows change under it.

* feat(git): add the rollout-scoped rollback transitions

Completes the deferred-state set. The three aliases write the same recovery
columns under the same rules as the recovery events, differing only in
provenance: Direct Git recovery emits `recovery_*`, and a later rollout
producer emits these. Nothing in this PR writes them, which is why they are
tested directly rather than through a caller.

`partial` is the one failure class they add, for a rollback that reached some
targets and not others. Completing has no unproven variant on purpose: a
rollback nobody can bind to a generation has nothing to complete against, so
it refuses without one and refuses a generation another application owns.

Also covers the recovery health reservation end to end: reserving writes and
links the row without arming anything, a replay reuses the run rather than
opening a second observation of the same restore, arming inserts nothing and
supersedes a conflicting stack gate, an unarmed reservation is written off
once, and a reservation that outlived its process is finalized rather than
armed. The per-row startup sweep is pinned too, since one row whose write
fails leaving the others finalized is the entire reason it replaced a bulk
update.

* feat(git): add the Blueprint source and deployment transitions

The first slice of the Blueprint work: the store side, with no production
caller yet. The routes and the reconciler are wired to these next, and pinning
the contract first means a caller cannot quietly satisfy a different one.

What these enforce is that a terminal event names the request it answers. A
deploy records the intent and candidate it was launched for, and a terminal is
accepted only against that same request, matching stage and identity on one
side rather than either in isolation. Without that pairing a deploy could be
acknowledged out of an in-flight withdraw, or one request's intent stored
against another's candidate. An acknowledgement carries the candidate from the
matched request rather than from its own payload, for the same reason.

Releasing an operation now releases its identity with it, and a resolved
interruption is retired rather than left to match again. Both were leaking
through the shared helpers: a stale identity let a later start resurrect a
superseded intent as live, and a surviving interruption both reported
completion as unknown for ever and let a late acknowledgement regress the
target after two later deploys had succeeded. Fixing the helpers fixes the
Direct paths too.

A start refuses to displace an unrelated operation, which would otherwise
abandon it with no terminal event and no history saying so. Observations write
the latest stage, since the placement facet reads it to decide whether a
stateful deployment is waiting on confirmation, and history alone could never
reach a reader.

Minting is left to the caller. A no-op Blueprint edit must mint no intent,
because a fresh identity would invalidate acknowledgements that are still
accurate.

* feat(git): record Blueprint create, edit, pin, and delete

Wires the Blueprint routes to the revision state. Each producer writes the
Blueprint source row and its GitOps rows in one transaction, so an operator
never sees a Blueprint that exists with nothing describing what it means, or an
intent for a Blueprint that failed to save.

The question these answer is when an edit invalidates what the fleet already
acknowledged. Changing the name, the compose content, the selector, the drift
mode, or whether it is enabled changes what nodes run or where, so each mints a
new intent and candidate. Changing the description or the classification
changes how the Blueprint reads and nothing a node can observe, so it updates
the source row alone: minting there would make every acknowledgement report as
stale over a reworded sentence.

An edit is measured by value, not by which fields were submitted. The editor
sends every field on every save, and the source layer decides what to
invalidate from which keys are present, so only the keys that genuinely differ
are passed down. Without that the two disagreed: rewording a description
advanced the revision past the one the current intent describes and cleared the
approval, while this layer classified it as metadata and minted nothing.
Selectors compare by value too, so reordering a list that names the same nodes
is not a placement change.

That measurement also changes one behaviour: a save that alters nothing now
leaves the approval alone, where before any save cleared it. Nothing changed,
so nothing is invalidated.

Pinning revises placement the same way a selector edit does, and re-pinning the
node already pinned is not a change. Deleting tombstones the application and
its live targets so the Blueprint stops claiming its slot, and withdraws
nothing itself, because the route has already done that and doing it twice
would record removals that never happened.

The required node set is stored in a canonical order, so reordering it is not
mistaken for a placement change. A Blueprint that predates the model has no
application yet and is left for migration rather than given a first intent
here, which would claim a starting point for deployments nobody has reconciled.

Desired nodes are passed in rather than computed here: the reconciler that
knows how to compute them reaches this layer, and importing it back would close
a module cycle.

* feat(git): record the placement a label or cordon moves

A label and a cordon say nothing about any one Blueprint, but both change which
nodes a selector matches. Each now revises placement for whichever Blueprints
the change actually moved, by comparing the desired nodes either side of the
write rather than reacting to the event.

That comparison is the whole point. Labelling a node no selector mentions, or
cordoning one no Blueprint wanted, moves nothing, and minting an intent for it
would invalidate every acknowledgement in the fleet over an edit no node can
observe. The same comparison covers a Blueprint pinned to a cordoned node: a
cordon governs automatic placement only, so the pinned target still wants that
node and its set does not move, without needing a case of its own. Nodes are
compared as a set, so the same nodes returned in a different order is not a
change either.

Each route wraps its existing write and the recording in one transaction, so a
recording failure cannot leave a fleet selecting on a label nothing recorded.
The write itself is untouched, which keeps label validation and the per-node
limit where they already live rather than restating them in a producer.

Node deletion already retires its targets, so this adds nothing there.

* feat(git): record Blueprint deployments by what caused them

Every production write to a Blueprint deployment row now goes through one
funnel that records the cause. The cause is carried rather than inferred from
the resulting status, because several causes land on the same one: a deploy
that failed and a withdraw that failed both read `failed`, and they mean
opposite things about whether the deployment is still on the node.

Recording is skipped when the status did not move, so a reconciler tick that
re-asserts a state it already reported does not append a second event
describing the same fact. The write still happens either way.

A terminal answers the request the target says it was given, not whatever the
Blueprint currently wants: an acknowledgement matched against the current
intent would accept work for a revision that node was never sent. The first
deploy to a node creates its target, since a Blueprint application has no
targets until something is sent somewhere.

Recording never fails the deployment. The rollout already happened, and turning
a bookkeeping problem into a stuck rollout would be the worse outcome.

The four reconciler observations record what was seen and nothing else. Preview
cleanup stays outside this path deliberately: it reverses a projection nobody
deployed, so recording it would report removals that never happened.

* fix(git): record an Inline Blueprint coming into existence

Creating a Blueprint inserted its application row directly, so the history
began at the first intent and described an application nothing recorded coming
into existence. Every other application-creation path emits the activation
event, and the event's own definition covers this case: an Inline Blueprint
inserts the application, the first intent, and the first candidate.

Activation goes through a transition now, which also enforces the one thing the
direct insert could not: a Blueprint gets one live application, refused rather
than silently duplicated.

No target is created. A Blueprint application has no targets until something is
deployed somewhere, unlike a Direct one which always has the node its stack
lives on.

* fix(git): file withdraw failures as withdraw failures

Four of the five withdraw failure paths were recorded as failed deploys. Only
the thrown one was tagged correctly, and it is the rare case: a delete-lock
conflict, a network error, a remote 409, and a remote non-200 all return rather
than throw, and all four were filed under deploy.

That inverted the thing this funnel exists to prevent. Recording a deploy
failure clears the in-flight operation, so a routine "another operation is
already in progress" wiped the withdraw that had just started and left the
target reporting a failed deploy while the deployment was still on the node.

A withdraw refused because the on-disk stack is not ours is now its own cause
too, rather than borrowing the deploy-side name conflict.

The status guard no longer suppresses starts. A start writes the identity that
terminals are matched against, so skipping one because the row already read
`deploying` let a later acknowledgement answer a request that had been
superseded, reporting a node as converged on a revision it was not running. The
guard now covers observations only, which is where repetition is the reconciler
re-asserting a state it already reported. A newer deploy supersedes an older
one so a redeploy of a stuck request can take over; anything else in flight is
still refused rather than displaced with no terminal event.

A rejection is logged differently from an infrastructure error, because a
target the model keeps refusing holds its active slot and stops recording
anything further, and that is worth seeing.

* feat(git): migrate pre-existing Blueprints into the revision state

Blueprints that predate the model are brought in at boot: an application, an
intent describing what the Blueprint currently asks for, and a candidate marked
as coming from the legacy inline record.

None of it is an acknowledgement. The Blueprint revision is carried for display
and nothing else, because a revision number is not evidence that any node is
running what it names, and recording it as agreement would report a fleet as
converged on an intent nobody verified. No targets are created for the same
reason, and the candidate's required set is empty: migration records what is
asked for, never which nodes currently satisfy it.

An approval authorizes the intent it was given for, so a Blueprint that was
never approved, or edited since, is recorded as needing reapproval rather than
left blank. Blank would read the same as an approval that is merely absent.

Replay is decided by the same scope, schema version, and fingerprint the Git
migration uses, and a Blueprint the new path already described is skipped
outright, since its rows were written with proof this pass does not have.

Blueprints migrate in their own pass. Coupling them to the Git migration would
let one unreadable Git stack keep every Blueprint outside the model.

* fix(git): contain the blueprint stack-directory probe at its call

The ownership probe resolved a path from the blueprint name and checked it
through the shared helper, which the security scan does not credit as a
barrier. The check now sits with the call it protects, matching the form used
elsewhere for the same reason.

The path was already validated, so this changes no behaviour. It surfaced now
because an earlier commit in this branch rewrote the file's line endings, which
made every line read as new and brought a pre-existing call into the scan's
changed-code window.

* feat(git): read GitOps history and carry revision state on source reads

Adds the instance-local history API and the additive revision fields the
source reads have been missing.

`GET /api/git-sources` and `GET /api/stacks/:stackName/git-source` now
carry `gitopsRevision` and `stackResourcePresent`. Only the instance that
owns the files can say whether a stack directory is really there, so that
answer travels with the response rather than being inferred by a reader
that has never seen the filesystem. It is read through the strict stack
listing: the lenient one answers a failed directory read with an empty
list, which here would read as every stack having vanished and would turn
an unreadable disk into an empty audit trail.

Two history routes land with them: `GET /api/git-sources/history` across
stacks, and `GET /api/stacks/:stackName/git-source/history` for one. Both
page on `(created_at, id)` so rows written inside the same millisecond
survive a page boundary, and both bound how far one request will scan. A
malformed cursor or an unusable filter value is refused rather than
ignored, because answering "show me the failures" with everything reads
as an answer instead of a non-answer.

Authorization is decided per row. A row reduces to a stack read only when
it names a stack, its application is live, and the stack is present on
disk; anything unprovable stays with Admin. The cross-stack routes
classify every row, while the per-stack route is authorized whole by
name, so reading a stack shows its full history including entries from an
earlier application. That distinction is carried in the scope type rather
than a flag, so skipping the row classifier without pinning the query to
the authorized stack cannot be expressed.

History entries record the fields each transition moved rather than a
whole revision, so lifecycle for the classifier comes from the owning
application row. That keeps authorization off the recorded payload
entirely: an entry whose detail cannot be read still returns its identity,
stage, and outcome with a stated limitation, and the decode failure is
logged rather than passed over in silence.

* feat(git): let auditors read GitOps history they cannot tie to a stack

A history entry that cannot be tied to a readable stack fell to Admin,
which left the auditor role seeing exactly what a viewer sees on the one
surface it exists to read.

History entries are an audit trail: insert-only, and recording the actor,
trigger, stage, and outcome of every transition. The request audit log is
already gated on the audit permission, so an entry whose audience cannot
be narrowed now falls there too. Withholding it protected nothing in any
case, since the request log already records that a Git-source mutation
happened and who made it.

The source collection deliberately keeps its Admin fallback. Those rows
are live Git configuration, not a record of events, and a mandate to
audit does not extend to reading the repository, ref, and credentials
policy of stacks that were deleted or never finished being created.

The fallback is now part of the requirement type rather than a role
comparison, so each surface states which audience it falls back to and
the compiler requires every case to be answered.

* feat(git): correct remote node identities on GitOps reads

A remote instance numbers its own nodes and has never heard of the hub's
numbering, so every node id it reports is a statement in its own
namespace. Read straight through, a hub joining two nodes showed two
different machines as the same node.

Four GETs now take a second hop that buffers the response and corrects
those identities: the git-source list and detail, and both history
routes. Everything else keeps streaming exactly as before, which is the
reason this is a separate hop rather than a mode of the existing one:
logs, downloads, and event streams must never be buffered, and the
enumerated positions rewritten here do not appear in them.

Only JSON numbers are replaced. A null node is preserved, since "no node"
is a fact the remote is entitled to state and inventing one there would
claim a placement that does not exist. Strings, application ids, and
stack names travel untouched.

The hub re-authorizes what comes back. A remote authorized its rows for
the machine account the hub proxies with, which says nothing about the
person behind the request, so every row is classified again against the
signed-in user. To make that possible without the hub holding another
instance's database, each history entry now carries the lifecycle of its
owning application alongside the stack-presence answer already there.
Both are validated fail-closed, and a verdict a remote might volunteer is
ignored: the hub decides, the remote supplies evidence.

Asking one node for another node's history is refused before the hop
rather than forwarded, because the remote would answer about itself and
the page would read as an answer to a question nobody asked. When the
node asked for is the node being talked to, the hub translates that into
a flag the remote resolves to its own default node. Only the hub may set
that flag, and a remote honours it only on a proxied hop.

Every way this hop can end converges on one terminal answer: rewritten,
passed through, too large, undecodable, failed upstream, or abandoned by
the client. A body the hub could not read never borrows the remote's
success status.

* test(git): assert the unlinked git-source response field by field

The detach test compared the whole response to `{ linked: false }`, which
stopped holding once that route started carrying the additive revision
fields. The equivalent backend route test was updated when those fields
landed; this one was missed.

Asserted field by field rather than loosened to a partial match, so the
two new fields are actually checked: a detached stack has no application
to project while its directory is still on disk.

* feat(git): carry GitOps revisions on blueprint, node, and drift responses

Completes the additive JSON half of the revision-state read contract, so the
Blueprint, node, and drift surfaces report GitOps state through the same
projection the Git-source routes already return instead of leaving it
unreadable.

Blueprint list, detail, create, update, and pin carry gitopsRevision. Node-label
add, cordon, uncordon, and node delete carry gitopsRevisions, ordered by
blueprint id and covering only the Blueprints the mutation actually moved: an
edit no selector reacts to reports an empty list rather than invalidating the
whole catalog. Node deletion reads its Blueprint owners inside the deletion
transaction and before the tombstone, which is the last moment a target row can
be traced back to an application, and an orphaned target is logged rather than
being silently indistinguishable from a Direct one. Both DELETEs that answered
204 still answer 204.

The drift GET and re-check gain the same field alongside the existing ledger,
which is untouched; no GitOps class is written into stack_drift_findings.

Reads and mutations treat a projection fault differently, on purpose. On a read
the revision is part of the answer, so a fault surfaces. On a mutation the write
has already committed, so the revision is decoration and is built defensively:
letting it throw would answer a successful cordon or a completed node deletion
with a 500, sending the operator to retry a hard delete that already happened
and be told the node does not exist. It degrades to an empty list and logs.

Because both drift routes answer with that projection, the identity hop now
intercepts them too, matching its route table per method instead of rejecting
every non-GET. Left as they were, one object would carry the hub's node
numbering or the remote's depending only on which route asked for it. The
allowlist names a single mutation rather than opening a verb, and the hub's
collection filter still keys off the git-source paths alone, so drift payloads
are rewritten without being re-authorized: they are per-stack and already
authorized by name before the hop.

Tests cover the exact shapes, the preserved 204s, revision ordering, the
empty-list cases, node deletion reporting its tombstoned Blueprints and
surviving a projection failure, and the proxy's new route matching, drift
rewrite, and filter exclusion.

* fix(git): resolve every application a GitOps surface can own

Three gaps found while building the additive JSON, all the same shape: an id or
a sentinel stood in for a row, so "there is nothing here" and "the thing that
should be here is unreachable" collapsed into one answer nobody could tell
apart.

A Blueprint application is stored with stack_name NULL, so no lookup by stack
name could reach it, while the reconciler materializes every Blueprint as a
stack directory of that name. A stack's Drift tab and Git panel therefore
reported no GitOps at all for a stack GitOps was actively managing, while the
Blueprint page reported a live application for the same thing. Stack surfaces
now bridge the two through the deployment row for the asking node. The
deployment row is what makes it safe: Blueprint and stack names share one
namespace, so matching on name alone would let a Blueprint claim an unrelated
stack of the same name on a node it never targeted.

A retired application was unreachable for the same reason, because every entry
point filtered to the live rows. Tombstoning deliberately keeps the configured
identity and SHA pointers as frozen facts so the projection can still say what
an application was, and the source deriver has a not_live status waiting for
exactly that, but nothing could hand it one. A detached Blueprint or stack now
reports what it was instead of reading as one that never existed.

The not-applicable projection was typed so it could not carry a reason, which
made a row that vanished between resolving it and re-reading it by id identical
to a stack the model was never asked about. That one field widens to hold
limitations, and the missing case now says so with the id as evidence. Every
other missing-row path in the deriver already worked this way.

Also logs, rather than silently skipping, a live application whose blueprint row
is gone: with cascade off that is a real integrity fault, the placement did
move, and the response would otherwise report that nothing had.

Cordon and uncordon are deliberately left reporting nothing. A cordon governs
whether new placements may be made, not what a Blueprint asks for, and the
reconciler applies it only to new placements, so revising intent for it would
invalidate acknowledgements fleet-wide over a change that evicts nothing. Two
comments implied otherwise and now state the contract.

* fix(git): keep stack-state resolution out of the Git-source read path

Review of the previous commit found the fallback it added had reached further
than intended. The resolver is now split by the question it answers.

projectStackRevision answers "what Git source is attached to this stack" and
stays Direct-only. projectManagedStackRevision answers "what manages this stack"
and is what the drift routes use. The Git-source routes must not be answered
with another application's identity, and there was a second reason: the row
classifier takes the stack name from the Git-source row but the lifecycle from
whatever the projection resolved, so a Blueprint application arriving there
would have turned an Admin-only row into one any stack grant could read. The
Blueprint that unlocked it could be the very one holding name_conflict because
of that stack.

The Blueprint-to-stack bridge also needed the right predicate rather than a
present deployment row. name_conflict is written precisely when a stack of that
name already exists on the node and Sencho does not own it, so guarding only
against withdrawn admitted exactly the collision the bridge exists to prevent.
It now requires last_deployed_at, which proves this Blueprint wrote the
directory, and excludes name_conflict and withdrawn: the same predicate the
delete and withdraw paths use. Only a live Blueprint application qualifies,
since a retired one has no claim on the directory.

The detached fallback no longer resolves deleted applications. Detached means
the files are still on disk and still describe that stack; deleted means the
stack is gone, so any directory of that name now belongs to something else and
reporting the old repository and SHA against it would disclose one stack's Git
identity through another's name. The authorization side already refused deleted
rows for that reason, so the two now agree. Ordering breaks ties on rowid rather
than a random UUID, and two partial indexes cover the new lookups, which the
existing live-only unique indexes could not serve.

The not-applicable variant's limitations are readonly, so pushing onto the
shared frozen instance is a compile error again rather than a runtime throw.

Tests cover a Blueprint claiming a stack it never deployed onto and one it hit a
name conflict on, a detached Direct source projecting not_live, and a deleted
one staying unresolved for a reused name. Drift fixtures move to afterEach so a
failing assertion cannot leak state into the next test.

* fix(git): stop the stack resolver falling through proven ownership

A second review round found the resolution chain could answer with the wrong
application, and that part of the previous commit was reading a state nothing
writes.

The Blueprint bridge returned one value for two different facts. Once the
deployment row proved a Blueprint had written the directory, a missing
application row for it still answered "no Blueprint here", so the chain
continued to the detached-Direct lookup. A stack that once had Direct Git, was
detached, and whose directory a Blueprint later took over would then report the
old Direct application's repository, ref, and SHA as that directory's state.
The bridge now separates "not mine" from "mine, but broken": the second logs and
returns its own limitation, so the fault is visible and no unrelated identity is
offered in its place.

A Blueprint projection also carried the whole placement roster into a route
authorized by a grant on one stack name. Targets are now scoped to the node
being asked about, which is both the safe answer and the accurate one, since the
question is what manages this directory here.

The detached-Blueprint lookup, its index, and its fallback are removed. Blueprint
retirement writes deleted, never detached, and the only detached writer is the
Direct detach path, so the getter could never match and the index covered an
empty set. The test that seemed to cover it built the state by hand and was
green against something the product cannot produce; it now drives the real
delete route and asserts what that path actually writes.

The Git-source resolver goes back to live applications only. Detach removes the
source row in the same transaction that tombstones the application, so a source
row beside a detached one is not producible, and the route's row classifier
takes its stack name from the source row but its lifecycle from the projection.
Keeping that resolver narrow is what stops a resolution change from quietly
moving who may read a row. The comment saying so is restored and now explains
why it must stay true.

* refactor(git): share the GitOps application fixture across the route tests

Two test files carried near-identical 58-line GitOpsApplicationRow literals that
differed only in a repo URL nothing asserted on. Both now import one helper.

The drift tests also repeated a Blueprint seed and its activation across four
cases, and re-imported DatabaseService per test; those become local helpers and
one hoisted import, which lets the cleanup hook go synchronous. Node lookup
throws on a missing default instead of casting.

projectBlueprintRevisions is module-private again: its only caller is the
committed-projection wrapper beside it.

A third copy of the same fixture remains in git-source-routes.test.ts. It is
left alone deliberately: it pins timestamps at 1 rather than now, and that file
has ordering-sensitive history assertions that would need auditing first.

* feat(git): mirror the GitOps revision contract on the frontend

Adds the client-side read contract for the GitOps revision projection, with
no consumers yet.

types/gitops.ts is a hand-written mirror of the backend read contract, in the
same convention as the other domain type files: the frontend never imports
backend. The projection's two arms are named separately so a component can
take the live one as a prop without re-narrowing, and the absent arm keeps its
absent keys rather than nulls so reaching for a lifecycle status without
narrowing is a compile error. The limitation code stays an open string, since
the backend adds codes without a schema bump and an exhaustive switch would
silently stop rendering the newest ones.

lib/gitopsState.ts is the one place a facet status becomes words and a colour,
so a state reads the same in a sidebar tooltip, a panel banner and the drift
tab. Both maps are keyed on the closed status unions, so widening the mirror
without naming the new state fails this build.

Every line of copy states the condition the deriver actually tests, which is
not always what the status name suggests. recovery_required is a recovery
already running rather than one that is needed. candidate_ready is reached only
when review is not required, so it is ready to apply, not ready to review.
synced_and_healthy is also reached with the health gate switched off and never
compares the deployed generation against the accepted one, so it claims
neither. never_reconciled means nothing has been accepted; a fetch that
produced no materialization still records its commit.

pendingSourceStatus keys "an update is waiting" on the candidate pointer rather
than the status name, because source_reconcile_required is reachable both from
a stale candidate and from an accepted generation with no candidate at all.
Retirement is excluded first: tombstoning keeps the candidate pointer as a
frozen fact, so a stack detached mid-review still carries one.

absentFault separates the two facts the empty projection carries. Empty
limitations means the model was never asked about this stack and the right
rendering is nothing; a non-empty list means an application that was expected
could not be reached. A live application's limitations are caveats on state
that is being reported, not faults, so they are deliberately excluded.

* feat(git): name the Git source state on the stack and sidebar surfaces

The sidebar indicator and the anatomy source row both read a raw pending
commit pointer, so a candidate blocked by local conflicts, held for review, or
stale against the configuration in force all render identically to one that is
ready to apply. The projection distinguishes them; nothing was reading it.

refreshGitSourcePending now derives each stack's state from the projection and
the pending map carries that state rather than a boolean. Presence in the map
still means exactly what it meant before, because it is keyed on the candidate
pointer rather than the status name: source_reconcile_required is reachable
both from a stale candidate and from an accepted generation with no candidate,
and only the first is something to review. One narrow fallback remains, for a
row with no projection at all: a failed GitOps write is logged and swallowed
while the pending commit still commits, so the flat pointer is the only thing
that can answer there. Wherever a projection exists it is the sole authority.

The sidebar keeps one indicator, in the same slot, at the same size and colour,
with the same position in the priority ladder. Only the tooltip changes, so a
blocked plan now says so instead of reading as an ordinary update. A test
asserts the rendered indicator is identical across states, which is what keeps
the rendered sidebar unchanged.

The anatomy source row keeps its pulsing dot alone for the ordinary case and
adds the state word only for the states the dot cannot express. Inline rather
than in a tooltip, since this is the reason something is stuck.

* feat(git): surface derived GitOps state in the Git source and Drift panels

The Git source panel's pending banner had two things it could say, read from a
raw commit pointer and a single blocked boolean. It now names which of four
states the candidate is actually in, so a plan blocked by local conflicts, one
held for review, and one gone stale against the configuration in force stop
rendering as the same ordinary update. The short commit sha stays.

A new card above it reports an application the projection could not reach. That
case renders as nothing at all today, which is indistinguishable from a stack
the model was never asked about. Empty limitations stays silent, because that
is the ordinary answer for most stacks and a header over an empty block would
be worse than nothing.

The summary block gains one row naming the source state. It is the first place
the panel can show applying, retry scheduled, suspended, recovering, or a
detached source, none of which have a pending commit and so never reach the
banner. Both existing rows are untouched and the last applied commit stays a
display fact.

The revision lives in its own state rather than on the Git source type, because
the PUT that saves this panel answers with a bare source and no revision. A
save drops it instead of rendering a state the write has already moved.

The Drift tab gains a third block below the two it already has. The compose
versus runtime card and the since-last-deploy card keep their exact positions
and copy; the new block answers a different question, which generation this node
was asked to run and whether it got there. A Blueprint-owned stack shows its
target rows and no source card, since a Blueprint application has no Git source
and inventing one would be a claim the model never made. The drift class list
renders against the type and expects no rows, because nothing populates it yet.

* feat(git): declare revision fields on the blueprint and node clients

Types first. The blueprint list, detail, create, update and pin responses all
carry a GitOps revision, and the node label add, cordon, uncordon and delete
responses carry a list of them. None of that was modelled, so the fields were
being dropped silently and the next reader would have taken these for bare
records.

Most of it is declared and deliberately unread, with the reason written where
the type lives. Create, update and pin are followed immediately by a re-read of
the catalog or the detail that carries the same projection, so rendering the
mutation's copy would show the same fact twice with one of them stale. Cordon
and uncordon always answer with an empty list by design, because a cordon
governs whether new placements may be made rather than what a Blueprint asks
for, and the reconciler leaves existing deployments where they are. The comment
on that type is the point: the risk there is a future reader building a
consumer for a list that is never populated.

Two places do report something. Deleting a node and adding a node label both
re-place Blueprints, and both now say how many. The count only, and only when
it is not zero: an empty list means both that nothing moved and that the
projection faulted after the write had already committed, so it can never be
reported as the first.

Blueprint detail gains the same unreachable-application card the Git source
panel has. A Blueprint with no live application row is a fact nothing in the
product could previously express, which is a different problem from having two
ways to say the same thing.

* fix(git): close the gaps review found in the GitOps frontend consumers

Six defects, each verified against the code before being fixed.

The pending-map read assumed every /git-sources row carries a projection. That
route is proxied, so a node predating the model answers rows without one, and
dereferencing it threw inside the loop. The catch swallowed the throw, the map
was never set, and every stack's Git indicator froze at its previous value for
as long as that node was selected. The field is optional now and a row without
one falls through to the same branch as a row with nothing to project.

That fallback also branched on the arm alone, so a projection reporting an
unreachable application plus a stale commit pointer was reported as a candidate
ready to apply. It now excludes a faulted projection: a fault means an
application was expected and could not be read, so the pointer is not evidence
that anything is ready, and naming a state there would be a guess.

Detaching a Git source cleared the source but not the revision, and the pending
card is derived from the revision alone. The panel kept advertising a waiting
commit for a stack Git no longer managed, behind a Review button that silently
did nothing. A read that threw had the same problem across stacks: the panel is
reused, so stack A's state could render under stack B's header.

The panel also lost the flat-pointer fallback the sidebar keeps, so the two
surfaces disagreed: the sidebar would show an indicator and clicking through
showed no card. Both now apply the same rule.

Node delete parsed the success body unguarded. The delete has already committed
at that point, so a malformed body would have reported a completed deletion as
a failure and skipped the refresh, leaving the deleted node on screen.

Tests: five mutations that previously survived now fail. The zero-pixel claim
compared the wrong element and passed when the indicator vanished entirely; the
stale-read test asserted during the loading window, when the body is skeletons
regardless; the sidebar passthrough test proved only that an indicator existed,
not that the state reached it. Added coverage for the drift row, an unknown
node, the not-applicable source guard, the save clearing, the Review button, and
a live application carrying a caveat, which must not read as a fault.

The fixture set drops two source statuses whose identity defaults describe a
state they cannot be in, and gains a drift-item builder.

* refactor(git): share the GitOps fault card and live-facet derivation

The unreachable-application card was built inline on three surfaces, and the
copies had already drifted: the Git source panel used one icon while the Drift
tab and the Blueprint sheet used another, so the same failure rendered two
different ways. It is one component now, which also owns the state key and the
test id those surfaces assert on.

Deriving the live source facet was likewise repeated, along with the two
semantic decisions behind it: the absent arm has no facets, and a source facet
of not applicable means a Blueprint owns the stack rather than that something
is missing. Both now live next to pendingSourceStatus, which already encoded
the same exclusion.

The Git source panel's seven derived values collapse to three, with the
pending-commit rule extracted to a named function so the four cases read
straight through instead of as nested ternaries. Behaviour is unchanged in
every case.

The two flat-pointer fallbacks are deliberately not unified: they look alike
but the panel also treats a live application whose source facet is not
applicable as unanswered, and merging the predicates would change what that
case renders.

* feat(git): count and announce committed GitOps transitions

Every history row that is actually inserted now produces one metric
increment and one state-invalidate event, so the surfaces that read GitOps
state hear about a change instead of waiting for the next poll.

Announcement is buffered and drained on a macrotask rather than fired
inline. better-sqlite3 is synchronous, so waiting for the macrotask puts
the drain after the transaction that wrote the row, and after any outer
transaction wrapping it, without having to detect which nesting depth it
is in. The drain confirms each row is still present before announcing it,
so a rolled-back transaction says nothing on its own, and the insert
declines to queue a dedupe replay, so a retry says nothing either. The
broadcaster is injected at startup rather than imported, keeping the
GitOps layer free of a cycle back into the notification stack.

Counters are process-local and in-memory, and their keyspace is finite by
construction: history stages are now a closed union that the build
enforces, and outcomes were already a closed set. The payload names no
stack, node, repository, or actor, because a counter carrying those would
be an audit trail with no retention rules and no per-row authorization,
which is what the history routes provide. GET /api/gitops-metrics is
Admin-only and instance-local, so selecting a node answers with that
node's counters.

On the client, a gitops-scoped invalidate refreshes the derived state
through a 250ms trailing window, matching the existing stack refresh. One
operation commits several transitions in a row and a first-boot migration
commits a great many, so refetching per event would thrash the API for a
picture that only settles at the end.

* feat(git): show GitOps source state on the stack dashboards

The dashboards list every stack on a node, which is where a fleet-wide
reading of Git state is most useful and where, until now, a Git-backed
stack looked exactly like a local one. Each row that the model has
something to say about carries a chip naming its source state.

The chip reads from the same status vocabulary the panels and the sidebar
use, so a stack cannot be "pending update" in one place and something else
in another. It sits beside the stack name rather than replacing the source
column: the column says where the files come from, the chip says what
GitOps makes of them, and those are different facts. The label is a word
and the title is a whole sentence, so the state never rests on colour
alone; the phone rows keep the word on screen, since touch has no hover.

The join is by stack name, which is what the dashboards have, and its
source is the same proxied route the sidebar reads. A row without a
revision, a Blueprint-owned application, and a projection fault all leave
the stack unbadged rather than inventing a state for it. State arrives by
announcement rather than by poll, since it only moves on a transition, and
a node switch blanks the map first: stack names repeat across nodes, so a
slow answer for the node just left is discarded rather than allowed to
label the wrong stacks.

* feat(git): say which part of a GitOps state could not be proven

Eighteen conditions can qualify a live projection: a manifest that does
not match the branch configured now, an approval that could not be
restored after a recovery, an artifact record that has gone. Every one was
already recorded and none of them reached the operator, so a state with a
hole in it read exactly like one without.

Each now has operator wording under the state it qualifies, checked
against the site that emits it rather than against the code's name, since
several names describe something narrower or wider than the condition
actually tested. The stored messages stay out of the UI: they are written
for a log reader, and some are raw decoder errors.

The presentation is deliberately quiet. A caveat is not a failure: the
state above it is real, and one piece of evidence behind it is missing, so
the reader learns which part to distrust without being told the whole
thing is broken. Faults keep their own card, because those replace the
state rather than qualifying it, and the two arms are read through
separate helpers so neither can be rendered as the other.

A code with no wording names itself rather than vanishing, so a node
running ahead of this build degrades to something honest instead of
reporting full confidence in a state its own backend flagged.

* fix(git): record a stateful first placement and keep a half-built Blueprint editable

Two faults that today's product cannot reach, and that the Git-backed
Blueprint mode would reach immediately.

The live-application lookup answers with an application that is active or
still being created, because its other callers ask whether the Blueprint
already holds the live slot, where a half-built row counts. Three
producers passed that answer straight into transitions that accept only an
active application and reject anything else by throwing, inside the
caller's own transaction. A Blueprint edited or pinned while its
application was still being created would have failed with a server error
and rolled the Blueprint write back with it. The producers now narrow to
what they actually require, through one shared predicate, leaving the slot
check honest.

Separately, a stateful placement is held for operator review before
anything is deployed, so no target exists when that hold is recorded, and
the observation was dropped for want of one. The hold left no trace: no
history row, and nothing on the target to say the node had been asked to
hold anything. First contact now creates the target, exactly as the first
deploy does. Nothing else changes with it: no intent, no generation, no
operation, and connectivity stays unset, because a node that has only been
asked to hold something has not been contacted. Observations that follow a
deploy already have a target, so they are unaffected, and a drift or evict
report for a node nothing was ever sent to is still dropped.

* fix(git): keep the Git source panel's state after a save

The save answers with the source row and no revision, so the panel had
nothing to replace its copy with and dropped it. The result was a stack
that had just been reconfigured showing no GitOps state at all until the
panel was reopened, which reads as a stack the model knows nothing about
rather than one whose state has just moved.

It re-reads instead. Keeping the old copy was not an option either: a
material configuration change clears the staged candidate server side, so
the state genuinely has moved and the panel would have gone on offering a
commit that is no longer there. Only the server can say what replaced it.

The alternative was returning the revision on the save itself. That route
is proxied, and the identity hop rewrites node numbering for an enumerated
set of routes, so adding a revision to a response outside that set would
hand back a remote node's numbering unrewritten. Re-reading costs one
request on a low-frequency action and needs no change to the hop.

* docs(git): document what GitOps state means for an operator

A commit SHA says which files Sencho wrote. It does not say whether that
commit was accepted, whether something newer is waiting, or whether an
operation was interrupted halfway, and until now the docs had no words for
any of that.

Git Sources gains a source-state table and a section on what happens when
part of a state could not be proven, since a stack with missing evidence
looks identical to one with complete evidence unless the product says
otherwise. Drift Detection gains the gitops section and explains why it
answers a different question from the two signals above it: those compare
files and containers as they are now, this reports what has been proven
over time. The dashboard page describes the state chip and, importantly,
that it and the row tint are independent, so a healthy row carrying
"pending update" is not a contradiction.

The Blueprint page now separates its revision counter from GitOps state.
The counter labels the spec; it says nothing about whether a given node
has got there, and reading it as fleet truth is the mistake the note
prevents.

The tutorial's verification step names the source state it should show,
so a reader can tell the difference between files written and a commit
accepted.

Audited and left unchanged: health-gated updates and fleet federation.
Neither presents a SHA or a revision as canonical GitOps truth, so neither
needed correcting.

* docs(git): name which saves actually clear a staged commit

The note claimed saving the form always clears the staged commit and moves
the state to reconcile required. Only a change to what gets materialized
does that: the repository, the ref, the compose paths, the project
directory, or the env sync. Changing the token or the apply behavior
leaves a staged commit alone, because neither changes what would be
written, and telling an operator otherwise would have them pull again for
nothing.

* fix(git): say when a committed transition has nobody to announce it to

Two silences worth breaking, both found reviewing the error paths in this
slice.

A server that never installs the event sink still counts every transition
and still writes every history row, so the only symptom is that no client
ever refreshes and the UI is quietly as stale as it was before any of this
existed. It now says so, once rather than per row, since a boot migration
would otherwise fill the log with one fact.

The dashboard's source-state fetch dropped a non-ok response without a
word. A refusal there freezes every badge at its last value, which looks
exactly like a fleet where nothing has changed, so the status code is
logged rather than inferred from badges that stopped moving.

* fix(git): close the defects review found in the announcement slice

Five findings, two of them able to reach an operator.

The event sink was installed one line after the deletion reconcile, which
tombstones applications and targets and awaits inside its own loop. A
drain therefore landed while the sink was still absent, so those boot-time
transitions were counted and never announced, and the warning added for an
unwired sink would have fired on every boot with a prepared deletion
intent: the fastest way to teach an operator to ignore it when it means
something. The sink now precedes every reconcile and migration pass.

The first-placement target was created outside the transition that records
it. The observation runs in its own savepoint and can refuse, and the
caller deliberately lets the deployment commit whatever the record says,
so a refusal left an active target with no generation, no stage and no
history: a placement relationship the model never established, which the
delete path would later tombstone as if it were real. Both writes now
succeed or fail together.

The badge looked its status up in a map closed at compile time, behind two
casts, while the value arrives over a proxy from a node that may run a
newer vocabulary. An unmapped key dereferenced undefined inside a stack
row and would have taken the whole table down with it. The facet and its
status are now a discriminated union, the lookup is optional, and an
unrecognised status renders nothing, which is what the join already does
for a stack it has no state for.

The dashboard's source-state loop derived each row inside the shared
try, so one row in an unexpected shape abandoned the loop and froze every
badge at its last value with nothing on screen to say so. Each row is now
guarded on its own and the count of unreadable ones is logged.

Two tests claimed things they did not check: both asserted a word was on
screen with a matcher that also matches screen-reader-only text, so a
compact badge would have kept them green, and one compared a label by
containment where the shorter label is a prefix of the longer. Both now
assert the visible node by equality. Added coverage for the window event
the dashboard actually refetches on, an unknown status, a row that cannot
be read, node-switch blanking, and a caveat recorded twice. One fixture
built a status pair the deriver cannot emit and was corrected.

Also: the guard narrowing Blueprint producers to an active application
excludes nothing today, because every Blueprint-mode application is
inserted as active and only Direct mode can be creating. The guard stays,
since the getter's slot semantics and the transitions' requirement have
drifted apart once already, but its rationale and its tests now say
plainly that they pin a defensive guard rather than reachable behaviour.

* refactor(git): make three runtime guards visible to the compiler

Type review found the same shape three times: a correct guard whose
necessity was asserted in a comment the type system could not see. This
project does not set noUncheckedIndexedAccess, so each of these read as
dead code to anything that trusts the types, and the guards protect the
behaviours that matter most here.

The limitation copy map typed its values optional, so the fallback for an
unrecognised code is now something the compiler requires rather than
something a comment explains. The badge reads its status through partial
views of the two vocabularies, so a miss is a fact TypeScript derives:
deleting the guard that stops one unknown status taking down a stack list
now fails the build, where before it compiled. Neither needed a cast.

The event payload has to be a type alias rather than an interface, because
only the former gets an implicit index signature and the broadcaster takes
an open envelope. That requirement now fails at the declaration instead of
surfacing in the startup wiring as an unexplained index-signature error.

Also added the reverse of the copy-coverage check: a code retired from the
backend leaving stale wording behind was invisible, since the fallback
only fires for entries that are missing rather than ones that linger.

* fix(git): make the state cards survive an unknown status, and correct the copy

Comment review found the protection added for the dashboard chip was
claimed more widely than it held. Five other surfaces indexed the status
vocabularies directly and passed the result straight to a card that
dereferences it, so the exact input the comments said would take a list
down still threw in the Git source panel, the Drift tab, the sidebar
tooltip and the stack anatomy row. The card now takes an optional state
and renders nothing without one, which also means a new surface cannot
reintroduce the dereference by rendering a card the ordinary way, and the
remaining four read through the lookups.

Copy corrections, each against the site that emits it rather than the
name of the code:

The manifest-identity caveat said the manifest named a different
repository or branch. It also fires when the manifest has no identity
block at all, or identifies another node or another stack, so it now says
it does not identify this stack on this node from the repository
configured now. The last-known-good artifact caveat covered a mismatch but
not the row being gone, which is the other half of the same condition.

Doc corrections: a Blueprint-delivered stack was described as carrying the
dashboard chip and a source state on the Drift tab, and it carries
neither, because a Blueprint has no Git source of its own. Per-node
Blueprint state was attributed to the detail sheet's GitOps section, which
only reports what could not be read or proven; the Deployments table is
where per-node status lives. Clearing a staged commit was said to always
land on reconcile required, but a stack that has never had a commit
accepted lands on never reconciled. The caveat lines were described as
sitting under the state when they render above the form. And the pending
banner was still documented as webhook-only, which this branch changed:
it now appears for any staged commit and its heading is the source state.

Also corrected two claims in the code: the observation branch does not
skip for a Blueprint that migration brought in, because migration creates
no targets, and the only refusal reachable from that call site is a
tombstoned target.

* refactor(git): simplify the announcement slice without moving any behaviour

A simplification sweep over the new code, all of it behaviour-preserving
and re-checked by breaking each guard afterwards.

The drain now separates policy from mechanism: it decides whether a row
survived, counts it, and either warns or hands it to a small announce
helper that owns the envelope and its error handling. The row-to-state
pass in the dashboard hook lifts out of the fetch, so one function does
the request and another does the derivation. The caveat dedupe collapses
to a set, which preserves first-insertion order and so keeps the ordering
its own docstring promises. The badge renders one span with a conditional
class rather than two that differed only by class. The metrics service
drops an internal type that was byte-identical to the one it exports.

One assertion goes: the observation branch tested membership and then
re-asserted the same fact to index the stage map, and a type predicate
lets the compiler carry it instead.

Everything the sweep was told to leave alone is intact: the buffered
drain, the row-existence re-check, the injected sink, the one-shot
warning, the partial-view lookups and their guards, the optional copy map
with its fallback, and the generation counter with its per-row guard.

* fix(git): close audit findings in the GitOps revision model

Five corrections from the pre-merge audit of this branch.

Migration no longer certifies a commit it cannot prove. The manifest read
now carries its resolved commit, and a manifest that names a different
commit than the source row records as applied is refused: the applied
directory materializes one commit while the row names another, so
trusting them together would mint a generation pointing at files that are
not the ones it claims. A manifest that names no commit at all, which is
what adoption writes, is a separate answer rather than a disagreement,
because reporting it as one would name a commit the manifest does not
contain. Both are recorded as evidence and the projection asks for a
fetch.

Create cleanup proves containment against the real filesystem before it
deletes. Every check on these paths was lexical, so a symlink or Windows
junction above the target read as contained while the recursive delete
followed it out of the managed area. Resolution keeps "is not there"
apart from "could not be read": only a genuinely missing path lets the
walk climb to an ancestor, because treating an unreadable one as absent
would infer containment for the single path whose link status could not
be established. Both sides are resolved, so relocating the data directory
onto another volume keeps working, and an area that does not exist at all
is nothing to delete rather than a suspected escape. The marker write
takes the same barrier as the marker delete, so a link cannot be written
through and then refused on the way out, wedging the stack name.

Startup stops while an interrupted create is unresolved, matching the
restore reconcile above it. A create that could not be settled leaves a
stack directory the deploy path cannot tell apart from a finished one, so
the alternative to stopping is letting a scheduler, webhook or operator
act on a half-built stack. Only that blocks: once the staged directories
are gone the create is torn down, and a staging marker nobody could
unlink is reported rather than thrown, so one failed unlink is not the
difference between an instance that boots and one that does not.

Per-stack history no longer exposes a predecessor through a reused stack
name. The grant covers the application holding the name now, which is
what keeps a stack's own entries readable while it is still being
created; every other row on that name is classified per row, so entries
belonging to an application that held the name earlier need the audit
permission, as they already did across stacks. A detached predecessor is
still readable, which is the classifier's own standing decision about
detach rather than a gap here, and is now pinned by a test.

Blueprint observations reach a reader. The reconciler recorded state
review, evict blocked, drifted and correcting against the target and
nothing projected them, so a deployed Blueprint could report itself as
never applied. The runtime facet now projects all four, below the states
a live or failed mutation puts the target in and above the pointer
checks, and any later transition supersedes the observation. The map is
declared total over the stages the reconciler can record, so a stage
added without a projection fails the build rather than silently reading
as never applied again.

* fix(git): confine managed-area cleanup to each stack's own location

Cleanup proved only that a resolved path landed somewhere inside the
managed area. That is satisfied by every other stack and every other node
in it, so a link from one stack's generations directory into another's
passed the check while the delete took a generation belonging to someone
else. The check is now positional: the managed area is resolved once, so
relocating the data directory onto another volume still works, and every
segment below it must be reached without redirection. The create-path
sinks use it on the write as well as the delete, so a claim cannot be
written through a link and then refused by the hardened delete, and the
manifest service's pre-existing deletion sinks (generation pruning,
boot-sweep orphan reaping, detach staging and finalization, and
whole-area removal, including after a restored snapshot) run it before
their recursive deletes too. Each of those sinks also keeps a literal
containment comparison beside the positional check, because static
analysis credits only a comparison at the call and reports the delete
otherwise. A refusal names both paths in the log, because it can hold
the boot gate.

Boot recovery dropped the checkpoint of an application that had left the
creating state without first clearing its staging marker. That left a
claim on the stack name with nothing to retry it, and every later create
for that name was refused by a marker nothing could remove. The settled
branches now share one exit, so the marker ordering holds for all of them.

A detached application's history was readable on a stack grant because
its files are still the stack standing at that name. Nothing in these
tables can prove it still is: a Blueprint successor records the name off
the application row, and a plain Compose stack recreated at the name
leaves no trace at all, so no detection-based allowance can be made
sound. Detach now moves an application's trail to the audit audience
outright, the same answer deleted and creating predecessors get, while
reading the stack itself stays where it was.

* fix(gitops): close four audit findings in revision state and identity proxy

A1: derive runtime and health against target desired_generation_id
- deriveRuntime returns 'applied_not_deployed' when desired != deployed, so a
  stale deployment stays deploy-pending instead of reading synced_and_healthy
- deriveHealth judges against desired_generation_id falling back to deployed
  when null; control case proves null-desired rows unchanged
- collectRuntimeDrift emits the plan-pinned runtime drift item for exact/
  qualified observation mismatches; equal/non-comparable observations emit
  nothing; ordering pin keeps deploy question first

A2: stop upstream 304 responses from bypassing hub rewrite and reauthorization
- identity proxy answers no-store on every terminal (rewrite, 204, 304,
  generated failures); validators (etag, last-modified, cache-control,
  expires, vary) no longer forwarded
- conditional request headers (if-none-match, if-modified-since, if-match,
  if-unmodified-since) stripped on the identity-hop branch of the shared
  proxyReq handler; streaming hop untouched (optimistic-concurrency file writes
  depend on If-Match/If-Unmodified-Since)
- supertest integration test drives the real middleware through a loopback
  capture server and asserts if-none-match absent while accept survives

S1: migrate legacy query/userinfo URLs via secret-free path
- parseLegacyRepoUrl strips userinfo, query, fragment instead of refusing;
  parseHttpsRepoUrl remains the strict gate for user-driven paths
- migrationDirectSourceIdentity uses the tolerant parser; operational
  stack_git_sources.repo_url untouched; fingerprint convergence proven
- trusted-manifest-on-legacy-URL test covers the worst real-world instance

S2: emit runtime artifact drift for current evidence
- top-level drift array carries the seven-class runtime item; frontend
  comments updated to reflect backend now emits runtime drift

All regression tests added and passing. CI green (7,374 backend + 2,790
frontend tests; only pre-existing Windows EBUSY teardown failure unrelated).

* test(frontend): fix DriftPanel drift item rendering test

Update test assertions to match the new backend drift item format.
The  function now renders artifact_set expected
identities as 'artifact <id> · <qualification>' and runtime_artifact
observed identities as the raw identity string.

* fix(gitops): emit a runtime drift item for desired-versus-deployed mismatch

The projection reported applied_not_deployed with no entry in drift, so
the canonical drift list contradicted the runtime facet it travels with.
collectRuntimeDrift now emits the r27 generation-mismatch item for that
state: desired generation as expected, deployed generation as observed,
ComposeService as owner. The action mirrors what availableActions offers:
deploy, unless an application-level fetch or apply is in flight or a
recovery is in progress, which withhold deploying without removing the
fact of the mismatch. The item clears once the desired generation deploys.

The stale-deployment test pins the exact item shape, its stability across
re-derivation, its convergence removal, and the withheld-action case; the
ordering pin keeps the artifact observation suppressed while the deploy
question stands. Frontend fixture aligns its artifact-mismatch example
with the backend's none action.

* fix(gitops): keep generation drift visible across failure and pause states

The desired-versus-deployed drift item was keyed to the applied_not_deployed
runtime status, but paused, failed, recovering, interrupted, and in-flight
statuses all outrank the pointer comparison in the deriver. A failed deploy
of a new generation over a running older one therefore dropped the drift
report exactly while the old workload was still serving.

The item is now judged from the pointers themselves: known and different
means reported, whatever presentation status the target carries. Retired
targets are excluded because nothing can rebind them, so their surviving
pointer divergence would be permanently unresolvable noise. The action
follows what the payload offers: deploy only when no application-level
operation or recovery withholds it and availableActions lists deploying,
none otherwise.

The failed-redeploy regression test pins the exact item shape after a
pre-mutation deploy failure, its stability across re-derivation, continued
artifact suppression for the replaced workload, and convergence removal;
a second pin keeps retirement silent.

* fix(gitops): complete the available-action legality matrix

The action list transcribed only part of the approved rules. An
interrupted apply offered apply without checking that its recorded
generation was still the current candidate; deploy fired for any target
reading applied_not_deployed regardless of target mode, with no retry
for an interrupted Direct deploy and none of the Blueprint interruption
rules; approve_legacy sat in the type union with no producer, leaving a
pending Inline placement review permanently unactionable through the
projection; and drift items inherited whatever deploy recommendation any
sibling target earned.

Actions now follow the matrix. Deploy is decided per target through one
predicate shared by deriveActions and the drift items: a paused or
failed sibling can no longer inherit another target's legal deploy, a
Blueprint-mode divergence never advertises Direct deployment, an
interrupted Direct deploy retries against the generation still applied,
and an interrupted Blueprint deploy or withdraw repeats only while its
recorded intent revision and rollout candidate still equal what the
application requires, where an absent pair matches because inline
Blueprints carry no candidate until the later-phase producer lands.
Interrupted apply requires the recorded generation to still be the
current candidate and the source not to have been suspended meanwhile.
A reachable Inline placement review now offers approve_legacy.

Six tests pin the matrix: sibling isolation across failed and paused
targets, the Blueprint mode guard, vacuous and matched Blueprint retry
identities plus the superseded case, matching-versus-stale Direct deploy
and apply interruptions, and the legacy review action.

* fix(gitops): withhold apply retry while the candidate is blocked

The interrupted-apply retry checked that the recorded generation still
matched the current candidate and that the source was not suspended, but
a later classification can also block that candidate, and applyStarted
refuses a blocked one outright. The retry is offered only when every
precondition the transition enforces still holds.

* fix(gitops): prove apply preconditions and limit fetch to Direct

The interrupted-apply retry checked identity, suspension, and blockage
but never loaded the candidate generation, so apply could be recommended
for a row that was missing, owned by another application, or built from
a superseded materialization fingerprint; applyStarted refuses all
three, and the positive fixture itself described a state no transition
would accept. The gate now proves existence, ownership, and fingerprint
before offering apply. Its fixture is rebuilt around a transition-legal
candidate whose projected action is executed against applyStarted, with
negatives for every refusal including suspension.

Fetch was offered to any application whose source looked unreconciled,
but the approved rules reserve fetch for live Direct applications; a
Git-backed Blueprint divergence now advertises nothing of the sort.
Controls pin both modes against equivalent source state.

* fix(gitops): fail closed when a candidate row is missing or foreign

Ordinary candidate_ready checked a fingerprint only when the candidate
generation row existed, so a dangling id or one owned by another
application fell through to ready and offered an apply that
applyStarted would refuse on sight. The source now requires the row to
exist under this application before readiness, records a limitation
for the anomaly with its operator copy mirrored in the frontend, and
reports reconcile-required instead. The Blueprint retry test also pins
that a superseded rollout candidate alone, with the intent still
matching, suppresses deployment.

* fix(gitops): fail accepted-generation derivation closed

The acceptance branch compared fingerprint and sha only when the
accepted generation row loaded, so a dangling id or one owned by
another application fell through and reported
application_generation_accepted with no evidence behind it, while also
withholding fetch as the recovery. The source now requires the row to
exist under this application before any comparison, records an
accepted_generation_invalid limitation naming the pointer with its
operator copy and inventory entry mirrored on the frontend, and
reports reconcile-required instead. Five scenarios pin valid, missing,
foreign, fingerprint-mismatched, and sha-mismatched acceptances.

* fix(gitops): enforce canonical state in dismiss, reconcile, and pulls

- Route dismiss-pending through the canonical dismissed transition so a
  refusal while an operation is in flight surfaces as 409 instead of
  clearing staged state behind the model's back
- Treat tombstoned targets as authoritative in both reconciler decision
  surfaces so ticks never redeploy onto a placement the model severed;
  an explicit deploy revives the target and records the revival delta
- Keep application-level rows in node-scoped history pages so proxied
  hub views do not read as if the application never came into being
- Expose repoIdentity and configuredRef on history items to match the
  server-side identity filters
- Stand down when a pull resolves to exactly the live candidate (same
  commit, source fingerprint, plan verdict); pulls after an acceptance
  still open a fresh staging generation as the apply target

* fix(rbac): classify git-source manifest reads as stack:read

GET /stacks/:name/git-source/manifest had no rule in the hub route
classifier, so proxied requests were refused with 403 before reaching
the remote node even for callers holding stack read access.

* fix(gitops): surface an error toast when dismiss is refused as in-flight

Dismissing a pending Git update while a fetch or apply is still running
now returns 409 OPERATION_IN_FLIGHT, but the frontend handler had no
else branch for a non-2xx response, so the refusal was swallowed with
no operator feedback at all. Add the same error-toast pattern already
used by the sibling apply handler, and order the success toast before
its side effects so a downstream failure cannot invert the outcome.
2026-08-27 10:25:27 -04:00
Anso 3c4c057467 feat(git): classify managed-file changes before apply (#1832)
* feat(git): classify managed-file changes before apply

Pull now builds a fingerprint-bound plan of adds, modifies, deletes, and local conflicts. Apply refuses stale or blocked plans instead of overwriting live files, and promotion stays the only filesystem mutator.

* fix(git): contain stack-dir probes before filesystem access

The missing-stack and root-.env existence checks now resolve against the compose base and refuse paths that escape it before lstat or existsSync.

* fix(git): address managed-file change plan audit blockers

Wire build-context live inventory into the planner, reject special file nodes without readFile, fingerprint configured project env files, enrich plan metadata, and compute the create plan before promotion. Redact drift ledger service keys for managed-path conflicts and clear pending plan columns on revision reset.

* fix(git): unblock change-plan CI sinks and fifo test

Hash stack files through a contained open plus fstat on the same handle so CodeQL no longer flags the lstat/read race, and create fifo fixtures with mkfifo instead of mkfifoSync.

* fix(git): preserve unowned context files and align candidate validation

Inspect prior and candidate build contexts together, delete only owned paths, reject context-root symlinks before walking, and validate with the env-file model deploy will use after promotion.

* fix(git): contain live context and candidate env path sinks

Inline resolve and startsWith at the lstat and access calls so containment is checked at the filesystem sink.

* fix(git): resolve live context walks from the compose root

Rebuild readdir, lstat, and access paths from the compose directory at each sink so containment is checked against a known-safe base.

* fix(git): validate synced env removal against post-promotion files

A managed .env that the next revision omits must not be used for candidate validation or invocation, because promotion deletes it. Context walks now bound directory entries and skip descendants under nested symlinks. Plan fingerprints bind review metadata and secret-path matching covers .env.* names.

* docs(git): capture classified change-plan review screenshots

Replace the old Monaco pull-preview images with the classified operation list used by Apply.

* fix(git): treat invocation drift as reviewable, not a file conflict

A live Compose command-line change is not a managed-file conflict. Reviewed apply records the incoming invocation; webhook auto-apply still refuses.
2026-08-14 09:53:31 -04:00
Anso 454e647a3c docs: write Give a Teammate Deploy Access to One Stack tutorial (#1800)
Adds a new Tutorials entry (not part of the original 14-page batch)
covering scoped RBAC permissions: create a Viewer account, grant a
Deployer scope on one stack, and verify the boundary from both the
teammate's session and the audit log.
2026-08-08 13:12:45 -04:00
Anso de7ecee497 docs: tutorials batch 1 (#1656)
* docs: scaffold Tutorials tab and write enroll-a-remote-node

Adds the Tutorials tab to docs.json with 15 stub pages across three
groups (Fleet & nodes, Deploy & automate, Secure & integrate), and
writes the first full tutorial: enrolling a remote node via Pilot
Agent mode, verified end to end against a live control instance and
a second host running an existing Jellyfin Compose stack.

* docs: write Schedule an Operation tutorial

* docs: fix MDX parse error in Schedule an Operation tutorial

* docs: write Set Up SSO with Custom OIDC tutorial

Registers an OAuth client in a self-hosted identity provider (Keycloak
worked example), configures Sencho's Custom OIDC settings, tests the
connection, and verifies a real end-to-end login with auto-provisioning
from two independent surfaces.

* docs: drop unused SSO tutorial screenshot

sso-settings-empty.png isn't referenced by the tutorial content.

* docs: write Set Up Fleet Federation tutorial

Migrates a Blueprint-managed workload from one node to another using
pin and cordon, with the confirm-before-mutate rollout in between.
Corrects the published feature page's claim that pin requires the
global admin role; the code gates cordon and pin identically, scoped
to the target node.

* docs: write Create and Approve a Blueprint tutorial

Covers labeling a target node, authoring a stateless Blueprint,
walking through the create-then-approve rollout flow, verifying
from the Deployments tab and the audit log, and recovering from a
port-conflict deploy failure. Cross-links with Move a Blueprint
Deployment to a New Node in both directions.

* docs: write Automatically Patch a Stack With an Auto-Update Label tutorial

* docs: write Configure Auto-Heal Policies tutorial

Adds the full step-by-step content for the Configure Auto-Heal Policies
stub: an nginx+redis scenario stack, adding a service-scoped policy,
and a live verification that breaks a container's healthcheck,
confirms the policy restarts it, and recovers it.

* docs: write Set Up Deploy Enforcement tutorial

Covers configuring a block-on-deploy scan policy against a stack running
a deliberately outdated nginx image, reading the block dialog, and
overriding it as an admin with the bypass confirmed in the audit log.
Includes a stack-pattern mismatch as the most likely first-time failure.

* docs: write Configure Environment Guardrails tutorial

Covers the Block deploy on missing required env vars guardrail end to
end: deploy a Postgres stack with a required password, enable the
guardrail, watch a real update get refused with a named-variable
message, fix it, and verify from the Activity and Environment tabs.

* docs: write Deploy a Stack Automatically From Your CI Pipeline tutorial

* docs: write Catch and Fix a Container That's Drifted From Its Compose File tutorial

Covers reading a real Drift finding after an out-of-band container
change and resolving it by redeploying through Sencho.

* docs: write Connect a Git Source tutorial

* docs: write Push a Shared Environment File to Every Node tutorial

Writes the Fleet Secrets tutorial: create a bundle, target nodes by
label, read the push preview/results, verify via the audit log, and
recover from a stack-name typo. Removes the three unwritten
placeholder stubs (RBAC, Sencho Mesh, private registries) that had no
scheduled content.
2026-08-08 01:49:21 -04:00