mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-10-25 21:20:25 +00:00
ab34e85b86
* 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.