# Changelog All notable changes to this project are documented in this file. Sections follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). **Headings do not**, deliberately — see below. **This file is generated from ATM** by the changelog sync; edit entries in ATM, not here, or the next release will overwrite them. Anything outside a version section — such as this note — survives regeneration. **What a heading names.** Keep a Changelog assumes one artifact with one number. This is a monorepo: a release moves some subset of independently versioned streams, and their numbers are meant to differ. So a heading names **the streams that actually moved**, which is this repository's own long-standing convention: The examples below are indented one space on purpose. Both ATM's changelog sync and `release-cli.yml` find releases by scanning for `##` at the start of a line, and neither knows what a fenced code block is — flush-left examples here become four releases, and a tag search answers with an illustration instead of its notes. Verified: before the indent, a lookup for `v0.2.75` returned the first line below. ``` ## cli v0.2.75 — … a CLI-only release ## platform-stack 0.2.77 / cli v0.2.74 — … both moved ## platform-stack 0.2.69 / operator v0.2.49 — … no CLI change, so no CLI tag ``` A CLI-only release carries no chart number because no chart was cut — the most common shape in this repository's history, not an edge case. The CLI is written with its `v`, identically to its git tag, which is what lets `release-cli.yml` find a release's notes by tag alone. Only two of the numbers are chosen by a human — the chart train and the CLI; the operator and cue-cmp versions cannot move without forcing a chart bump. `CLAUDE.md` § "Releases and version streams" holds the full map, and each chart release's record in `platform-stack/cue/compatibility.cue` carries its paired `operatorVersion` and change class. Everything from before ATM is frozen in `docs/changelog/history.md` — 237 sections of already-shipped work, kept because `release-cli.yml` still reads it for tags cut before the switch. ## platform-stack 0.2.81 / operator v0.2.53 / cli v0.2.79 — 2026-10-08 ### Upgrade notes Before a cluster moves to platform-stack 0.2.81: 1. **Install apprafter 0.2.79 first.** It prints the new `Reconcile:` lines in `apprafter app status`, `volume status` and `db status`, and on a platform rolled back below 0.2.81 it marks a `ReconcileStalled` row `NOT CURRENT`. Older CLIs print no `Reconcile:` line. They do list `ReconcileStalled`, like any condition they do not know, and from 0.2.52 an Application's `ReconcileTimedOut` among its problems. Nothing they already show changes meaning. 2. **Update anything that matches the `ForeignFieldManager` Event's note text** (see Changed), or match on its reason instead. The upgrade itself publishes no such Event. Until v0.2.53 replaces the operator, an outside write to the root Application's `spec.source` is still reported in the old wording, and an Event already published keeps it until it expires (one hour by default), so match both wordings until then. 3. **Approve the upgrade, and approve it again if asked.** 0.2.81 is classified `requires-restart`, so it waits for approval (`apprafter migration list`, then `apprafter migration approve `). The bump is made by the operator already running (v0.2.52, or an older one on a cluster below 0.2.80), which deletes the approved plan before it moves the platform and can lose the approval if that pass fails (fixed in this release). If `apprafter migration list` shows the plan pending again after you approved it, approve it again. The operator and the admission webhook restart once; no application, Postgres, Dragonfly or NATS pod restarts. Every existing PlatformStack condition is kept through the CRD change. **Redis data until the upgrade lands.** Operator v0.2.52 and earlier (platform-stack 0.2.80 and earlier) has two bugs that can flush Redis data (see Fixed): - A persistent `needs.redis` claim that is deleted and re-created within its 7-day grace period can lose its retained data if its first reattach fails part-way. Until the cluster runs 0.2.81, do not remove and re-add an application with a persistent `needs.redis` (`apprafter app remove --keep-data` keeps its claim), and do not drop and re-add its `needs.redis`. - A Redis shared database and a `needs.redis` claim allocated at the same moment can end up on one database number. Deleting the shared database then flushes the claim's data at once, and deleting the claim flushes the shared database's data when the claim's 7-day grace ends. Until the cluster runs 0.2.81, do not create a Redis shared database (`apprafter db create --type redis`) while an application with a new `needs.redis` claim is deploying, or the other way round. What the upgrade restarts, and what it does not, is in the 0.2.81 record of `platform-stack/cue/compatibility.cue`. ### Added - `apprafter status` and `apprafter platform status` know the PlatformStack's new `ReconcileStalled` condition: `apprafter status` lists it as a problem while it is `True`, and both commands mark the row `NOT CURRENT` on a platform older than 0.2.81, where an older operator carries a `True` it found forward without re-checking it. Older CLIs list the condition too, as one they do not recognise, but cannot tell a current one from one left behind by a rollback. (WI-400) - `apprafter volume status` and `apprafter db status` print a `Reconcile:` line, and `apprafter app status` a ` — Reconcile:` entry under its claims table, when the operator abandoned a pass over that SharedVolume, SharedDatabase or ResourceClaim at its reconcile deadline. It is the newest such `ReconcileTimedOut` Warning Event with its age; `volume status` prints, for example, `Reconcile: last timed out 3 minutes ago (did not finish within 60s); the operator retries on its own`. It goes away once a later pass of the controller that reported it changes the object's status, or when the apiserver expires the Event (one hour by default). A claim's size, capacity and key-count updates do not count, and a pass that recovers without changing the status leaves the line too, so read its age: a stall that persists times out again every few minutes and keeps the age short. Nothing is printed otherwise, against an operator older than this release, for an Event about an earlier object of the same name, or for a user who may not list `events.events.k8s.io` in the namespace. (WI-400) ### Changed - Documented the reconcile deadlines for WI-400: a "A status says a reconcile did not finish" section in the operator troubleshooting guide (every controller's deadline, where each timeout shows, how long the `Reconcile:` line stays, and what a repeating timeout points at); the reconcile deadline and its timeout in the shared-volume and platform-upgrade pages; the Postgres session bounds and the SourceCredential probe bounds in the shared-database and source-credential pages; the `apprafter_reconcile_timeouts_total` metric in `operator/README.md`; the walk diagnostics in `e2e/README.md`; and a feature-ledger row in `docs/status.md`. (WI-400) - PlatformStack `status.conditions` is a list keyed by `type` (CRD change). When a platform reconcile is abandoned at its 120s deadline, a second field manager, `apprafter-reconcile-deadline`, sets `ReconcileStalled=True`. It removes the condition after a later reconcile completes without an error (one that fails for another reason leaves it in place), and writes nothing while the served CRD keeps the list atomic (a rollback). `platform-controller` never writes that condition. After the upgrade it re-applies its own conditions once, so it owns each of them by key, and a condition retired in that same pass, or left behind by the older operator during the upgrade, is removed rather than kept for good. (WI-400) - Migration: Rolled back below 0.2.81, the CRD returns to an atomic list and the older operator carries `ReconcileStalled` forward as it found it: read it there as left behind, not as current. `apprafter` 0.2.79 marks the row NOT CURRENT on such a cluster. - The `ForeignFieldManager` Warning Event's note changes from "reverted external write to spec.source ... PlatformController force-reapplied desired state" to "detected external write to spec.source ... PlatformController is force-reapplying desired state". It is still published at detection, before the revert, and `SourceReverted` still follows once the operator has seen the revert land. Both are best-effort: a revert whose answer the operator never gets (its reconcile is cut at the deadline, or the operator restarts) leaves the Warning without a `SourceReverted`, so a missing `SourceReverted` does not by itself mean the revert failed. (WI-400) - Migration: Anything that matches the `ForeignFieldManager` Event's note text (alerts, log queries, scripts) must accept the new wording, and during the upgrade the old one too: the old operator emits it until it restarts, and its Events stay listed for up to an hour. Matching on the reason avoids both. The Event's reason (`ForeignFieldManager`), type (Warning) and action (`ForceRevert`) are unchanged. - `apprafter_reconcile_duration_seconds` gains buckets at 30, 60, 90, 120, 190 and 300 seconds; the default buckets are unchanged. A pass slower than 10s now lands in a bucket of its own instead of only in `+Inf`, and one abandoned at its deadline lands just above that deadline. Six controllers record this histogram: Application, ResourceClaim scheduling and provisioning, SharedVolume, SharedDatabase and SourceCredential. PlatformStack, RetainedClaim cleanup and MigrationPlan record no duration, so this histogram never shows their abandoned passes; `apprafter_reconcile_timeouts_total` counts them. An abandoned pass also counts as a reconcile error, on `apprafter_reconcile_errors_total{kind}` for every controller but MigrationPlan, so alerts on reconcile errors fire on timeouts too. (WI-400) - Documented the Redis database-number safety of WI-402 in the needs.redis and shared-database how-it-works pages and the Redis operator guide (a reattach that resumes instead of flushing, `AwaitingKeyspace` and `DbnumConflict`), and the reattach proof in `e2e/README.md`. (WI-402) - The needs.pg, needs.disk, needs.redis and needs.jetstream how-it-works pages no longer say that dropping a need from the manifest leaves its claim in place, or that the retention path runs only when the Application is deleted. Since operator v0.2.45 (platform-stack 0.2.59) an approved removal deletes the claim the Application no longer declares, and that claim then goes the way it does when the Application itself is deleted: a claim that owns its data enters the 7-day retention window, and a `ref` to a shared database or shared volume is released at once. (WI-415) ### Fixed - A platform registry that never answers, or answers too slowly ever to finish, now shows as `UpstreamReachable=False` (`PollFailed`) in `apprafter platform status` instead of freezing every condition, once the stack is pinned or has resolved a version before; an unpinned stack that never has, such as a freshly bootstrapped one, still writes no status until the registry answers. Each registry request gives up after 5s to connect (TCP and TLS) or 10s without an answer, and a registry question still unfinished after 20s reads "did not finish … within 20s". Unanswered backup reads show as `BackupHealthy=Unknown`. An approved platform upgrade keeps its approval until the controller has moved the platform's root Application to the new version, so an interrupted reconcile cannot cost it. An upgrade whose reconcile is cut at its deadline, or fails, after the controller saw its bump land is still recorded in `status.versionHistory` by the next reconcile that runs through to its final status write (the `ReconcileStalled` write that reports a cut does not carry it); the entry is held in memory until then, so an operator restart or leader change in that window still loses it. The PlatformStack controller now also counts `apprafter_reconcile_errors_total{kind="PlatformStack"}`. (WI-400) - A ready claim's background size and capacity refresh now gives up after 40s, and a disk claim's provisioning-time capacity sample after 20s, so a hung read costs the figure, never the claim. A SharedVolume's `CapacityWarning` Event is now sent before the status write that records it, so an interrupted reconcile repeats the Event rather than losing it. A SharedVolume deleted within 65s of a failed reconcile now holds its finalizer until 65s after that failure and then deletes its PVC again, so a PVC write the failed reconcile left in flight cannot leave an orphaned PVC behind. The operator remembers the failure in memory only, so an operator restart or leadership change, whether it cuts that reconcile short or comes between the failure and the delete, lets the delete release its finalizer at once. (WI-400) - An operator reconcile that stops making progress no longer silences its object. A controller never runs two reconciles of one object at once, and the claim provisioner, SharedVolume and SharedDatabase controllers run one at a time for all their objects, so a reconcile that never ended held every later change to that object, or to every object of that kind: one stuck request could leave an Application with no status, no events and no log line for minutes. The stall seen so far came from the Kubernetes client's connection pool and is fixed separately; the deadlines below are the backstop for any other cause. Every controller now gives up on a pass at a deadline and retries it: - Application, ResourceClaim scheduling and provisioning, RetainedClaim cleanup, SharedDatabase and PlatformStack: 120s; - SharedVolume: 60s; - SourceCredential: 90s; - MigrationPlan: 190s. A cut is never written into the status under the controller's own field manager, so it cannot prune a status field or turn a ready object not-ready; a status write the abandoned pass had already sent may still land. Each abandoned pass is counted in `apprafter_reconcile_timeouts_total{kind}`, which a healthy operator never increments: the metric appears only after the first timeout. The Application controller runs at most 16 reconciles at once. The calls a pass makes to Postgres, container registries, SourceCredential hosts, metrics endpoints and kubelet capacity reads each have a timeout of their own; an ordinary apiserver request has only the client's 295s socket read timeout, longer than every deadline, so the deadline is what ends an apiserver request that never answers. Where a timeout shows: - Application: as `ReconcileTimedOut` among its recent problems, written by its next pass, which `apprafter app status` prints. - Claims being scheduled or provisioned, SharedVolumes and SharedDatabases: a `ReconcileTimedOut` Warning Event, which `apprafter` 0.2.79 prints in `app status` (under the claims table), `volume status` and `db status`. - PlatformStack: `ReconcileStalled=True`, shown by `apprafter platform status` and `apprafter status`, beside a `ReconcileTimedOut` Warning Event on the stack for each cut. - SourceCredential, MigrationPlan and RetainedClaim cleanup: nothing on the object, only a WARN line in the operator log and the operator's metrics. Every timeout counts on `apprafter_reconcile_timeouts_total`, and all but MigrationPlan's also count on `apprafter_reconcile_errors_total`. (WI-400) - SourceCredential validity probes are bounded, so a host that never answers no longer holds the reconcile. Every probe still stops at 10s, and a registry probe now also gives up after 3s without a connection or a reply. Each distinct repository or image is probed once, at most four at a time per credential with the covered hosts taking turns, and each half (git, registry) has a 30s budget. A half that runs out of time keeps a verdict it already reached: a rejection still reads False, and a success reads True and adds how many representatives did not answer. A half with no verdict reads Unknown and says how many did not answer, or that the list that finds them did not return in time; `apprafter repo creds show` prints it, and `lastValidated` is kept across a pass that reached no verdict. (WI-400) - The resource-claim provisioner's Postgres admin sessions now time out: a 10s dial, a 10s lock wait and a 30s statement (enforced by the server), 45s per call. Before, a call waited for as long as it took, and since these controllers run one reconcile at a time, every other claim or shared database waited behind it. A shared database whose reader grants wait on a tenant's lock now reports `AwaitingLock` (or `StatementTimedOut`) and stays `Ready` once provisioned. A call that connects but gets no answer within 45s reads `AwaitingCluster` and leaves `Ready` as it was; both keep an existing `ExtensionUnavailable` warning. A dial that is refused or not answered within 10s reads as before (`Ready=False`, the cluster not answering yet). A pass makes its Postgres calls one after another, so on a slow but answering server a shared database with declared extensions can still reach its 120s deadline; it is retried. Deleting a consumer of a shared database whose role is locked no longer waits on the lock with no limit: each revoke gives up after 10s on the lock and is tried again 15s later, for up to 5 minutes from the delete, after which the claim is released and the role is left behind with a WARN naming it, to drop by hand. Metrics scrapes and kubelet capacity reads give up after 15s. A SharedVolume whose kubelet does not answer within 10s keeps its last capacity figure and warning, marked 'not re-measured', instead of losing them. (WI-400) - **Fixed — Redis (Dragonfly) data safety.** - A persistent Redis claim that is re-created within its 7-day grace period no longer loses its retained data when a provisioning attempt fails part-way, for example while the Dragonfly instance restarts. The retry resumes the reattach instead of flushing the database. If grace expires while such a claim is still re-attaching, its retention snapshot is kept until the claim is ready, counted as `apprafter_claim_gc_total{result="reattach-pending"}`. Affects operator v0.2.52 and earlier (platform-stack 0.2.80 and earlier) on any persistent needs.redis claim deleted and re-created within its grace period. - Logical-database allocation is now serialized between ResourceClaims and SharedDatabases, and a shared cache records its database number before it prepares it, so a claim and a shared cache allocating at the same moment now rarely land on the same number; when they still do (an allocation write cut off mid-request can land late), the re-check below keeps them from both using it. - Every `FLUSHDB` (claim provisioning, shared-cache creation and deletion, and the grace-period cleanup) first re-checks that no other claim, retention snapshot or shared database holds the same database number. If one does, nothing is flushed. A claim or shared cache that had not used the number yet reports `AwaitingKeyspace` naming the other holder and takes a different number on its next attempt. A claim re-attaching to retained data keeps its number and stays not ready with `DbnumConflict`, visible in `app status`, until the overlap is resolved. A shared-cache delete or grace-period cleanup completes without flushing. - A new shared cache now reads `AwaitingKeyspace`, naming no other holder, from the moment it records its number until it has checked and flushed it. That is the normal first step, not a conflict. (WI-402) - The operator no longer stalls a request behind a streaming watch. A race in the HTTP/1.1 connection pool of the client libraries (hyper/hyper-util) could hand a request to a connection whose watch was still streaming; the request then waited unsent until that watch ended, up to about five minutes, while the object showed nothing. It happened mostly on the first reconciles after the operator started or took over leadership, when its watchers all open their watches at once. The operator's Kubernetes client now opens a new connection for every request, one TCP and TLS handshake each, so a request can never wait behind a watch; nothing else about the client changes. The per-reconcile deadlines introduced in this release remain as a backstop. (WI-417) ### Internal - The kind e2e walks no longer fail in the last days of a month because ECR Public refused Argo CD's Redis image. Before bootstrap, they now put that image (public.ecr.aws/docker/library/redis, the argo-cd chart default) into each node from mirror.gcr.io, falling back to Docker Hub, under its ECR name, and with `IfNotPresent` the kubelet never asks ECR. It is the same image: its index digest (sha256:c1e88455…) was the same on all three registries when checked on 2026-10-02, and `scripts/check-ecr-seed.sh` warns (it does not fail) when ECR Public serves a different digest from the first seed source that serves the tag (mirror.gcr.io, else Docker Hub). One case stays exposed: a walk that tests an argo-cd chart bump not yet published, because Argo CD then re-syncs to the published platform-stack chart, whose older Redis image is not seeded. The cause was ECR Public's anonymous quota of 500 GB a month per source IP, which GitHub's shared runner IPs use up by month end. It failed one walk on 2026-08-31 and three on 2026-09-30 with "toomanyrequests: Data limit exceeded". The walks' bootstrap retry could not help, because it runs on the same IP. How it behaves: - The seed is best effort. If it cannot seed an image it prints a WARN, and the node pulls from ECR as before. - The real-Hetzner nightly does not seed, so the genuine anonymous ECR pull is still tested. - A new `scripts/check-ecr-seed.sh`, run in `just lint` and platform-stack-check, fails when the chart renders an ECR Public image that cannot be seeded. (WI-399) - `e2e/redis-reattach-proof.sh` proves on kind that a persistent `needs.redis` claim re-created within its grace period keeps its data when its first reattach fails after recording its allocation: it takes `ACL SETUSER` away from the instance's admin user while `FLUSHDB` stays allowed, and counts the keys after the claim recovers. It expects the keys to survive, against the working tree's operator (`APPRAFTER_E2E_LOCAL_OPERATOR=1`) or the published one. `PROOF_EXPECT=loss`, run against the published operator, shows that the proof sees the defect: it held against v0.2.52 before this release, and stops holding once platform-stack 0.2.81 is published, because bootstrap installs the newest published platform and the script cannot pin an older one, so a `loss` run then fails its verdict with the keys kept. (WI-402) - e2e: `dump_diagnostics` (`e2e/lib.sh`), which every walk the `.github/workflows/e2e-*.yml` workflows run calls when it fails (`mvp.sh` and some one-off walks and probes do not), now reads the whole log, since the walk started, of every container of every operator and admission-webhook pod that exists when the dump runs, plus the previous instance of any of those containers that restarted, with ANSI codes removed (a pod replaced earlier in the walk is not covered). Before the log tails it prints every operator log line from the walk that carries the deadline error `reconcile did not finish within s`, at least one per abandoned pass (usually two; one for a SourceCredential), then the last 2000 lines of each log. Where `APPRAFTER_E2E_DIAG_DIR` is set, as the e2e workflows do, it keeps everything, and the workflows upload it as the `e2e-diagnostics--` artifact when a job fails or is cancelled: the full logs, the not-Ready pods' logs, every event, every apprafter.io object and every Argo CD Application. The diagnostics dump no longer aborts a walk's teardown when the apiserver is unreachable. `scripts/check-dump-diagnostics.sh` guards it in `just lint`, pre-commit and CI. (WI-405) - Both Cargo lockfiles move `yoke-derive` from 0.8.3, which was yanked on crates.io, to 0.8.4, so `scripts/cargo-deny.sh` passes its advisory check again; nothing else in either lock moves. (WI-415) ## cli v0.2.78 — 2026-09-26 ### Added - `nix develop` now provides restic, which the CLI's local backup verbs (`backup create/list/show/check/prune`, `restore`, `export`) run. It is nixpkgs' restic (0.18.1), on the same minor the in-cluster backup runner asserts at build time; the dev shell reads that minor from `cli/apprafter-backup/Dockerfile` and raises an evaluation warning when a `flake.lock` update would move restic off it. (WI-395) ### Changed - Backup detail now lives in one place, `apprafter backup status`. `apprafter status` says in one line that backups are working and how long ago the last one ran (`Backups: working — last backup 10 hours ago.`), instead of repeating the operator's full messages. A problem still gets a short line of its own in plain words, pointing to `apprafter backup status`: `Backups: FAILING for 3 hours — no node has room for the backup runner.` (naming the weekly check when it is the check that fails), or `Retention: NOT ENFORCED — …`. Enforced retention adds no line, and neither does retention pruned from outside the cluster (the scoped S3 key, or `enforce: operator`) while `apprafter backup prune` has run in the last eight days, nor a first weekly check still to come within eight days — past that, each is said. `apprafter platform status` no longer prints a separate Backups/Retention block under its conditions table: the `BackupHealthy` and `BackupRetention` rows are the whole report, marked `NOT CURRENT` when an older operator left them behind after a rollback. `apprafter backup status` now shows why backups are failing, since when and what to run next — pointing at its own Jobs, runner and repository sections — and no longer repeats an enforced retention verdict its Repository block already shows. (WI-394) - The README, the specification, the feature status page and the ADRs now describe AppRafter's core as source-available rather than open source: it is licensed FSL-1.1-Apache-2.0, and each release converts to Apache 2.0 two years after it ships. The plugins (MIT) and the documentation (CC-BY-4.0) remain openly licensed. Nothing about the licence itself changed. (WI-396) ## platform-stack 0.2.80 / operator v0.2.52 / argocd-cue-cmp 0.1.29 / cli v0.2.77 — 2026-09-24 ### Upgrade notes Before a cluster moves to platform-stack 0.2.80: 1. **Install apprafter 0.2.77 first.** Its `apprafter backup status` is what shows a backup or check Job left by 0.2.79 that can never start, with the command that clears it (see Migration below); older CLIs show such a Job as Running. 2. **Decide retention.** A cluster that never set `spec.backup.retention.enforce`, and whose bucket key may delete, starts removing snapshots beyond its keep policy at its first weekly check after the upgrade. `apprafter backup set enforce operator` keeps the old behaviour; a cluster on the scoped key deletes nothing either way. 3. **Add `--timezone `** to any script that runs `apprafter backup prune` offline. What the upgrade restarts, and what it does not, is in the 0.2.80 record of `platform-stack/cue/compatibility.cue`. ### Added - Dependency-health gating, which the repo had none of: `cargo-deny` now runs in CI (test.yml) and in `just lint`, over both workspaces, from one shared `deny.toml` whose every ignore carries its reasoning inline. Its first run found five things the version watcher had never reported — the rsa/Marvin advisory plus four unmaintained crates (rustls-pemfile, backoff, derivative, instant), all four arriving through `kube` 0.95 and all four gone with this release's move to kube 4.2, which leaves the rsa/Marvin ignore as the only one. The watcher also gains a `held` class for pins that are on their newest release and still a dead end: a dead crate sits on its own final release forever, so a "are we behind?" check reports nothing for it and it reads healthier than a maintained crate that ships weekly. `serde_yaml` (archived, published as `0.9.34+deprecated`) is the case that neither gate would have caught alone. (WI-366) - A new `BackupHealthy` condition on the PlatformStack says why a backup cannot run, from what Kubernetes records rather than from the runner's last success: `RunnerUnschedulable`, `RunnerNotStarted`, `RunnerOOMKilled`, `RunnerEvicted`, `RunnerPreempted`, `RunnerStopped` (a drain or a deletion), `RunnerFailed`, `DeadlineExceeded`, `BackoffLimitExceeded`, `RepositoryCheckFailed`, `Failed` or `ScheduleSuspended`, naming the Job, the pod and the cause. `apprafter status` and `apprafter platform status` print it with the next step ("Backups: FAILING … RunnerPreempted"). The operator gains a read-only Role in `apprafter-system` (list and watch on cronjobs, jobs and pods). Argo CD health is deliberately unchanged, so a failing backup never blocks a sync. (WI-386) - `just lint` and the platform-stack-check workflow gain two render guards. `scripts/check-component-claim-templates.sh` renders every Helm component's own chart and fails on a StatefulSet volumeClaimTemplate that lacks a field the apiserver stores, the shape that kept the `nats` Application OutOfSync; it pulls each component's chart, so it needs the network (about 45 s). `scripts/check-backup-render.sh` renders both backup CronJobs with backups on and asserts their deadline, resources and restic settings, the rule that fails a staging overrun at once, and that the runner reads every `APPRAFTER_*` variable the chart sets, which is why platform-stack-check now also runs on changes to the runner's source. (WI-387) - A new `BackupRetention` condition on the PlatformStack says whether retention is actually enforced: `Pruned` / `NothingToPrune`, or `PruneNotPermitted` when the cluster's key may not delete (with the repository's size and its growth since the previous check), `PruneFailed`, `CheckFailed`, `CheckOff`, or `EnforcedOutsideCluster` for `enforce: operator`. `apprafter status` prints it under Backups with the command to run; `apprafter backup status` adds a Repository block (last check, last prune, size, growth). (WI-389) ### Changed - Provisioned nodes now install k3s from a pinned MINOR channel (`INSTALL_K3S_CHANNEL=v1.36`) instead of resolving `stable` at provision time. Two clusters built weeks apart previously ran different Kubernetes minors with nothing recording which, and a replaced node could come back on a different minor than its siblings. Pinning the minor rather than an exact version is deliberate: a newly provisioned node still gets the newest 1.36 patch without a code change (running nodes are not upgraded), while a jump to 1.37 becomes a reviewed commit. The in-cluster backup runner's restic is declared the same way — the Alpine tag used to choose it silently (3.21 gave 0.17.3, 3.24 gives 0.18.1), and the image build now fails loudly if the minor moves underneath it. (WI-352) - The `bun-http` starter template now runs on the distroless Node.js 24 image (`gcr.io/distroless/nodejs24-debian12:nonroot`) instead of Node.js 20, which reached end of life on 2026-04-30. It also builds with `oven/bun:1.4-debian` instead of the floating `oven/bun:1-debian`. Apps already created from the template are not changed; to move one, edit the two `FROM` lines in its Dockerfile. (WI-352) - CUE moves from v0.10.0 to v0.17.1 everywhere, including the Argo CD sidecar that evaluates your manifests. `cue.mod` language versions are deliberately untouched, and the sidecar's manifest-injection and discovery test suites pass unchanged under v0.17.1. The dev-shell toolchain also moves from the nixos-unstable branch to the nixos-26.05 release branch: unstable already carries Helm 4, and nixpkgs has no Helm 3 fallback, so a routine lockfile refresh would have swapped the local Helm major as a side effect. The dev shell, and the dev container now pinned to match it, keep Helm 3 (3.20) on purpose while CI moves to Helm 4.3.0, because the CLI runs whatever Helm the user has installed and has to work under both majors. (WI-353) - CI: every kind e2e cluster now runs Kubernetes v1.36.4 — production's minor, at its current patch — instead of v1.31.0. kind moves v0.24.0 -> v0.33.0 with the node image digest-pinned in the same commit, because kind supports a node image only with the release that ships it. Verified in a rootful microVM: `scripts/validate-crds.sh` PASSED (all CRDs Established plus eight server-side regressions) and `e2e/gitops-walk.sh` went GREEN in 5m30 on the new pair, so the whole Cilium -> Argo CD -> CMP -> operator loop is proven on production's Kubernetes minor. (WI-356) - The operator, admission webhook and scheduled backup runner are now built on kube-rs 4.2 and k8s-openapi 0.28 against the Kubernetes 1.36 API that production clusters run. The move itself leaves the CRDs and every timestamp these components write byte-identical; the operator and webhook pods restart once on the new images. Visible differences: Kubernetes Events the operator emits are now named `.` rather than `-`, and the backup runner now retries a transient apiserver 429, 503 or 504 within the request instead of failing the run. Building from source now needs Rust 1.89 or newer (CI and the release images use 1.98). (WI-357) - Operator: replaced the abandoned `oci-distribution` crate with `oci-client` — the same project after its rename and move to the ORAS org (0.11.0 was its last release in March 2024; oci-client is on 0.18.0). It sits on the registry path: ADR 0040 tag-to-digest resolution and the platform-stack chart pull. It was also what held `reqwest` and `sha2` down, so both moved with it (reqwest 0.12 → 0.13, sha2 0.10 → 0.11). (WI-365) - CI now installs Helm 4.3.0 (was 3.16.2, and 3.16.4 in release-operator.yml) in every workflow that uses Helm, including the real-Hetzner nightly, which had no pin and ran the runner image's Helm. The switch was gated on `e2e/gitops-walk.sh` on disk-backed kind (three green runs under 4.3.0, two control runs under 3.22.0); no bootstrap slowdown was measured. Helm 4 installs releases with server-side apply, which matters for re-running `cluster-bootstrap` against an existing cluster — tracked separately before the next Argo CD upgrade. (WI-368) - CI now runs the operator and CLI version-bump guards, the backup-runner image pin guard and the workflow linter on every pull request and every push to master, in lint.yml's new `version-guards` job — so commits made with `--no-verify`, web edits and merge commits no longer skip them; the cue-cmp sidecar drift check moves into the same job from its inline copy in argocd-cue-cmp-check.yml. A guard that cannot tell whether its version is already published (origin unreachable, no tag of its series visible, the tag not fetchable) now FAILS in CI instead of passing as "bump in flight"; locally the same case prints a warning that the check did not run. The dev container's feature lock (`.devcontainer/devcontainer-lock.json`) is now committed, so a rebuild installs the recorded feature releases; refresh it with `npx @devcontainers/cli upgrade --workspace-folder .`. The upstream version watcher reports the locked releases. (WI-373) - Backup runner pods now run at the new PriorityClass `apprafter-backup-runner` (value -1, `preemptionPolicy: Never`), rendered only while backups are enabled. A waiting runner no longer takes room a restarting platform pod needs, and a pod of default priority that needs a running runner's room preempts it; the Job then retries, and the stack reports the preemption (`RunnerPreempted`). During a platform upgrade on a full 4 GB node, the new operator and Argo CD repo-server pods preempt a running backup instead of waiting for it; a runner of a backup Job created before the upgrade to 0.2.80 keeps the default priority and does not give way. (WI-386) - Backup retention now runs inside the cluster by default. `spec.backup.retention.enforce` takes a third mode, `check`, which is the new default: the weekly check Job runs `restic check` and, only after a check that passed, prunes to the keep policy (7 daily / 4 weekly / 6 monthly unless configured) as far as the cluster's bucket key may delete. Before, the default was `operator`, and nothing in the cluster ever pruned, so the repository grew until someone ran `apprafter backup prune`. A scoped key deletes nothing and writes nothing. An explicitly set mode is kept; `cluster` still prunes after every backup; `apprafter backup set enforce` and `backup enable --enforce` accept `check`. The check Job now posts the failure webhook for a failed check or a failed prune (not for a refused one), and a prune that fails or is refused does not fail the Job. What the check reads is unchanged: besides the repository's structure it re-downloads and verifies a random 10% of the data each week, which the help for `backup enable --check` used to call metadata-only; `apprafter backup set check-depth structure|full` changes that. (BREAKING, WI-389) - Migration: A cluster that never set `spec.backup.retention.enforce` and whose bucket key may delete starts removing the snapshots beyond its keep policy at its first weekly check after upgrading to platform-stack 0.2.80. To keep the old behaviour, run `apprafter backup set enforce operator` before upgrading. - `apprafter backup prune` now refuses, deleting and writing nothing, when its key may not delete snapshots, and names `--credential-file` (before, it could report success, or fail after restic had written a new index object into the bucket). It counts the keep policy in `--timezone `, else in the cluster's `spec.backup.timeZone`; with neither (the offline form, with no cluster to read), it refuses before listing anything. A `--timezone` that differs from the cluster's zone is used, with a warning. It records itself as the cluster's last prune (`apprafter.io/last-prune`, shown by `backup status` and `BackupRetention`) only when it pruned that cluster's own repository and snapshots: a prune whose `--repo` or `--cluster-uid` names another no longer stamps the current cluster, and says so. (BREAKING, WI-389) - Migration: Offline `apprafter backup prune --repo … --keep-daily … --keep-weekly … --keep-monthly … --cluster-uid …` invocations must now also pass `--timezone `: the zone the backup schedules ran in, or `UTC` if they named none. ### Fixed - Release tooling: `scripts/check-version-coherence.sh` now also requires each operator chart's `version` to equal its `appVersion`. An operator bump that moved only `appVersion` would have re-published the old chart version and never produced the one platform-stack pins — a silent partial upgrade in which the operator stays on its old image while the root Application reports Synced/Healthy; an upgrade test of this release (0.2.79 -> 0.2.80) caught exactly that before it shipped. `just crd-validate` no longer writes its kind context into your `~/.kube/config` (it used to make it current, then unset current-context on cleanup). (WI-349) - CI: the Kubernetes version every kind e2e cluster runs is now written down (`APPRAFTER_KIND_NODE_IMAGE` in e2e/lib.sh, with matching defaults in scripts/validate-crds.sh and e2e/jetstream-nack-shape-check.sh). No call site passed `--image` before, so the kind BINARY silently chose it — meaning a "tooling" bump would have moved the tested apiserver as an invisible side effect. The image is digest-pinned (kindest/node v1.36.4), and the kind binary and this image must now move together. CI helm reconciled from the v3.16.2/v3.16.4 split to a single v4.3.0, kubectl to v1.37.0, yq to v4.53.6, just to 1.58.0. (WI-351) - `scripts/crd-check.sh` claimed it ran under `nix develop` so that cue was the flake.lock-pinned version, "ONE cue version across local + CI". That was false: the flake shipped whatever cue nixpkgs happened to carry (0.16.1) while CI's setup-cue installed 0.10.0 — six minors apart, so the CRDs were generated and byte-checked under a different evaluator from the one the Argo CD sidecar and every other cue-running CI job used. flake.nix now pins cue to an exact upstream release instead of taking nixpkgs', so all four pins (flake, workflows, sidecar Dockerfile, devcontainer) carry the same string, and the version watcher reads the flake as a fourth source so a future divergence is reported as drift. (WI-353) - Scheduled backups can no longer hang and silently block every later run. Both backup CronJobs now stop a Job at a deadline (6 hours by default; `apprafter backup set deadline` / `check-deadline`, or `spec.backup.activeDeadlineSeconds` / `checkActiveDeadlineSeconds`). A run stopped at its deadline records the failure, fires the failure webhook, removes its helper pods and lets restic release its repository lock. `pg_dump` gives up after 5 minutes waiting for table locks and after 10 minutes without output (a lock on a view, materialized view or sequence), and its server session ends with it. The runner no longer stalls when a command writes more than 1 KiB of error output, and `lastError` now carries that output. The failure webhook is bounded to 30 seconds and does not follow redirects. A single large dump or load is no longer cut off after one hour, and `apprafter backup create`, `export` and `restore` give each at least six hours. A helper pod left behind by an interrupted run is replaced instead of failing the next run, and `apprafter backup status` names why a Job failed. Note the default schedules: the 6-hour deadline is longer than the 3-hour gap to the Sunday check, so a Sunday backup past three hours costs that week's check or that backup — move the check with `apprafter backup set check 12:00`. (WI-357) - The dev container's setup step (`.devcontainer/post-create.sh`) works again. It ran `go install …/lefthook@latest`, which resolves to the last lefthook 1.x release; that no longer compiles under the newest Go, so the step exited 1 and lefthook (the repository's commit hooks) and cosign were never installed. It now installs lefthook 2.1, as well as Bun 1.4 and just 1.51, each at the newest patch of that minor. The container's features also pin kubectl 1.36 and Helm 3.20, and no longer install pnpm or minikube. (WI-364) - All four release version guards (`check-cli-version-bump`, `check-operator-version-bump`, `check-backup-runner-pin`, `check-argocd-cue-cmp-drift`) compared the last published tag against HEAD. As pre-commit hooks that made them blind to the commit being made — they passed on the commit that introduced a change and only fired on the next one, by which point the incomplete commit had landed. They now diff against the index (`--cached`), which at pre-commit time is the tree about to become the commit and in CI equals HEAD. (WI-365) - Argo CD's repo-server now renders at most two Applications at a time (`reposerver.parallelism.limit: 2`, previously unlimited) and its memory limit rises from 256Mi to 384Mi (request unchanged at 66Mi). After a cold start (node reboot, Argo CD upgrade) it used to render every Application at once and peak close to its limit, closer with every Application added; the peak now roughly halves and stays bounded. Applying this release restarts every Argo CD pod except Redis once. If a cluster with many Applications shows ComparisonError timeouts, raise the limit through `PlatformStack.spec.overrides.argocd.values.configs.params`. (WI-370) - CUE is now resolved through one script, `scripts/cue`, at the eight call sites (lint, the chart render, the platform-stack checks, build.rs) that each did `command -v cue || nix run nixpkgs#cue --`. Both of those branches give whatever the machine or nixpkgs happens to carry rather than the version `flake.nix` pins, which is how a local run came to evaluate the corpus under cue 0.16.0 while CI installed 0.10.0 — while `crd-check.sh` claimed in its header that there was one cue version across both. The `CUE` override that lint-cue.sh's header had documented since the script was written but never implemented now works, alongside the `CUE_BIN` the Rust side already used. The product is deliberately unchanged: `apprafter app validate` still runs the user's own cue against the user's manifests. (WI-371) - A brief registry outage no longer restarts your application twice. When the platform cannot look up the digest of an image tag, it now keeps the digest the application is already running and reports `ImageResolved=False` in the Application resource's status conditions, instead of switching the workload to the bare tag and back once the registry answers. A tag that really moved still rolls out on the next successful lookup. Also fixed in the operator: leader election now steps down before its Lease can be taken over, so a hung apiserver request can no longer leave two operators reconciling at once; an apiserver outage that keeps the operator from renewing its Lease for 20 seconds now restarts the operator pod instead. (WI-381) - Ctrl-C (or SIGTERM) during `apprafter backup create`, `apprafter export` or `apprafter restore` now removes the helper pods that command created — only pods the apiserver confirmed it created, so a scheduled backup's helper is never touched — within 15 seconds, then exits 130 (143 for SIGTERM). Previously the helper was left running for up to an hour. A restore stopped while `pg_restore --clean` waited to drop a table also no longer leaves that `DROP` queued on the database server, where it blocked every later query on the table: the restore's PostgreSQL session now ends within seconds of its helper pod, as the backup's `pg_dump` session does. From the signal on, the command starts nothing new: a `restore --data-only` stopped by a SIGTERM (as `timeout`, systemd or a cancelled CI job send it) does not go on to scale your applications down. A second Ctrl-C exits at once, without waiting for that cleanup; like any kill, it leaves the command's decrypted temporary kubeconfig in $TMPDIR. Nothing else is undone: an interrupted restore leaves its applications as they were at that moment. Start `apprafter backup create` only when `apprafter backup status` shows no scheduled backup in progress. (WI-383) - JetStream backup and restore helper pods, which run in the NATS server's namespace, are now named after the claim's namespace as well as the claim. Before, two applications in different namespaces with a JetStream claim of the same name shared one helper name, so a backup or restore of one that ran at the same time as the other's failed. A helper left over under an old name stays `Completed` until someone deletes it: `kubectl delete pod -n -l apprafter.io/backup-helper=true --field-selector=status.phase=Succeeded`. (WI-383) - The scheduled backup runner now fits a 4 GB node: both backup CronJobs request 128Mi (was 256Mi) with a 384Mi limit (was 512Mi), sized from measurements against a real bucket. restic runs with `GOMAXPROCS=2` and `GOMEMLIMIT=96MiB` and uploads 4 MiB packs, which keeps a 2 GB first backup near 107 MiB (a 400 MB first backup peaked at 108 MiB on a real 4 GB node). The 4 MiB packs mean about three times as many objects for new data (existing packs are kept), which matters on a store that bills per request. The runner now stages on its own volume, so `stagingSizeLimit` actually bounds the staged dumps; a run that outgrows it fails its Job at the first attempt, with a `lastError` naming the limit. The weekly check Job now works on a staging volume of the same `stagingSizeLimit` too: restic's cache and the packs a prune rewrites go there instead of into the container's writable layer, and a check or prune that outgrows it fails the Job at its first attempt, so a very large repository may need a larger limit. On a 4 GB node running a pg and a persistent redis claim, platform-stack 0.2.79's runner could not be scheduled and no nightly backup ran. The operator guide now states what a 4 GB node holds: the platform, the shared PostgreSQL, one Dragonfly instance for a persistent `needs.redis`, the backup runner while it runs, and about four small applications; a further backend instance (an ephemeral `needs.redis` class or `needs.jetstream`) does not fit, so its pod stays `Pending` and the application that declared it waits at `AwaitingResourceClaim`. (WI-386) - `apprafter --help`, `--version`, a mistyped command, and the commands that never leave your machine (`completion`, `app validate`, `app scaffold`, `target list|use|show|rename|remove`, `init`, `plan`, `login`, `upgrade-tier`, `auth`) no longer run the startup checks, so they no longer contact GitHub or query the cluster of your current kubeconfig context. Every other command keeps both checks; `APPRAFTER_SKIP_STARTUP_CHECKS` still turns them off. (WI-386) - `apprafter backup show` without a snapshot id and `apprafter restore` without `--snapshot` now use the newest complete backup run. After a sequential backup (`stagingMode: sequential`) stopped between its claims (a deadline, a preemption, Ctrl-C), both used to pick the unfinished run: `backup show` misread the repository and `restore` failed on the missing manifest — exactly when a restore is needed. Both now name any unfinished run newer than the one they use. They also compare snapshot times as instants rather than as text: restic records each time with its writer's UTC offset (the in-cluster runner writes UTC, `apprafter backup create` your machine's local time), so in a repository holding runs from both, `latest` could pick the older run. `restore --snapshot ` with a claim's snapshot id restores that claim's whole run, or refuses before downloading anything when the run never completed. (WI-386) - `apprafter backup status` and `apprafter backup run` now say when the backup runner's pod cannot be scheduled, instead of reporting it as Running. `backup status` shows such a Job as "Pending, cannot be scheduled: ", with what the runner requests, `apprafter top` and the troubleshooting section. `backup run` gives up after two minutes on a lack of room (ten on a node condition such as a pressure taint or cordon), deletes the Job it created so it cannot start by itself later beside the scheduled one, and fails with `apprafter::backup::runner_unschedulable`. It also refuses to start while another backup or check Job has not finished (`apprafter::backup::job_active`), and shows a Job between attempts as "Retrying". `backup run` now prints each failed attempt as it sees it, with the runner's own error or else the pod's reason, and when `--timeout` runs out after an attempt has failed it exits non-zero with that reason instead of 0, because that Job has taken no backup; a slow run with no failed attempt still ends the wait with a note and exit 0. Both commands now recognise a backup or check Job by the CronJob that owns it, as the operator does, rather than by its name: a Job made with `kubectl create job --from=cronjob/apprafter-backup ` used to be invisible to them, and `backup status` could report `Last backup Job: none` beside it. `backup enable` says "Backup IS enabled" when its first backup cannot start, and still exits non-zero. (WI-386) - `apprafter status` no longer calls an older channel release "upgrade available" (seen on a cluster whose running release was yanked): it compares versions by semver precedence, ignoring build metadata, and says the channel's newest release is older. `apprafter backup create` stamps its snapshots with the cluster's name instead of your machine's hostname, so `backup list` shows one cluster. (WI-386) - On a cluster where an app declares `needs.jetstream`, the `nats` Argo CD Application now reaches Synced and stays there. Before, it stayed OutOfSync (or Unknown) while Argo CD re-synced its StatefulSet every few minutes, so no "all applications Synced" check could pass. Upgrading does not change the running NATS server: no sync runs and the pod does not restart. Note that the NATS JetStream volume's size and storage class cannot be changed by editing them later — a StatefulSet's volume templates are immutable. (WI-387) - The backup keep policy now compares snapshot times as instants rather than as text, counts days, weeks and months in `spec.backup.timeZone` (UTC when unset) — the zone the backup schedules run in — and files each run under the day it started. Before, a workstation's `backup create` and a runner's run on the same day could keep the older one, a schedule near midnight could put two runs on one day at a clock change, and a nightly run that finished after midnight counted for the next day and pushed out one of the last seven. On a cluster with a time zone set, the first prune after the upgrade may keep a different set of runs than the old UTC count did. A run that a backup may still be writing is left alone until the backup deadline (`spec.backup.activeDeadlineSeconds`, never less than six hours) plus an hour has passed since its newest snapshot, so no prune (the check Job's, the backup Job's under `enforce: cluster`, or `apprafter backup prune`) sweeps a run still being written, and a lone claim snapshot no longer takes a complete run's keep slot. Every prune now forgets, verifies the store really deleted, and only then prunes: restic 0.18.1 reports success for a forget the store refused. (WI-389) - `apprafter backup set keep-daily|keep-weekly|keep-monthly ` no longer fails on a cluster that never set a retention mode, and neither does `apprafter backup enable` with a `--keep-*` flag but no `--enforce`. The PlatformStack CRD required `spec.backup.retention.enforce` whenever `retention` was present, so the apiserver refused these writes with `spec.backup.retention.enforce: Required value`. The mode is now optional, and when it is unset the platform default applies. (WI-389) - The platform-stack chart publish now also waits for the backup runner image and the operator and admission-webhook Helm charts it pins, as it already did for the operator, webhook and CUE sidecar images. The runner image is built by its own workflow and the operator charts are pushed only after both operator images exist, so a chart could previously publish pinning a runner image or component chart that did not exist yet; it now fails loudly after 15 minutes instead, naming the artefact and the workflow that publishes it. (WI-391) ### Security - Landing: cleared every critical and high advisory in the landing workspace (97 -> 20 reported by `bun audit`; 3 critical and 36 high -> 0). The load-bearing one is GHSA-2xp9-vwfh-vxw4, an unauthenticated RCE in the Next.js Image Optimization API reachable on the publicly served Payload CMS. Next 15.5.18 -> 16.3.5 with the Payload family 3.84.1 -> 3.90.1 (16.3.3 is the floor Payload's own peer range moved to for this CVE, and the whole 15.5.x line is excluded from it), astro 5.18.1 -> 7.3.3, nodemailer 6.10.1 -> 9.1.1, undici 7.24.4 -> 7.29.0, sharp 0.33.5 -> 0.35.4, plus the in-range transitive set. (WI-350) - CI: `DeterminateSystems/nix-installer-action` is no longer pinned to the mutable `@main` branch in crd-check.yml, docs.yml and release-docs.yml — the last of which holds `packages: write` and pushes the docs image the operator watches. Also bumped 14 GitHub Actions across their majors (11 of them node20 -> node24 and nothing else), with `cosign-release: v2.4.1` deliberately kept explicit under cosign-installer v4 so published signatures stay verifiable by consumers on cosign v2. (WI-351) - Rust dependencies refreshed across both workspaces; the lockfile move alone clears five RUSTSEC advisories, the load-bearing one being RUSTSEC-2026-0285 (rustls accepted TLS 1.3 handshake messages across encryption-level boundaries) — rustls is on the live path twice, via kube-client and the outbound HTTP clients. Also clears RUSTSEC-2025-0119 (unmaintained number_prefix, via indicatif) and RUSTSEC-2025-0057 (fxhash, via inquire). Base images move to Alpine 3.24: 3.20 went EOL 2026-04-01 and 3.21 reaches EOL 2026-11-01, so the Argo CD CUE sidecar had been building on an unsupported base and the backup runner was about to. (WI-352) - Platform components updated: cert-manager v1.16.2 -> v1.21.2 (back inside its supported Kubernetes window, 1.33-1.36; the built-in edit role no longer lets namespace editors create ACME Challenges or Orders, GHSA-8rvj-mm4h-c258; existing certificates are kept and each renewal now issues a fresh private key; its metrics Service port is renamed from `tcp-prometheus-servicemonitor` to `http-metrics`, so a scrape config that selects the old port name stops collecting), sealed-secrets 2.18.6 -> 2.20.0 (controller 0.40.0; keys and wire format unchanged, nothing to re-seal), and the Vertical Pod Autoscaler chart 0.11.0 -> 0.12.0 (VPA 1.7.1 unchanged; the permission the new chart dropped but VPA still needs to clean up recommendations of deleted apps is granted explicitly). The shared Postgres cluster now pins its PostgreSQL image to the build it already runs, so a future CloudNativePG upgrade can no longer change the PostgreSQL version as a side effect; no Postgres instance restarts on this upgrade. (WI-354) - Every GitHub Action in the workflows is now pinned by full commit SHA with its release as a trailing comment, and `scripts/check-workflows.sh` rejects any other form (a tag, even vX.Y.Z, can be re-pointed). Toolchains pin the minor and let the patch float — Rust 1.98 in CI, `cli/rust-toolchain.toml`, the builder images, mise and the dev container — with one deliberate exception: Node pins its LTS major (24), because Node never patches an old minor. The release images build `--locked` and never let rustup download a compiler; the backup runner's restic minor is asserted at build time and watched; the upstream version watcher now reads every one of these pins, the dev container's included. (WI-364) - Backup, export and restore helper pods no longer carry the database or NATS password in their Pod spec: they read it by reference from the Secret that already holds it (the claim's connection Secret, or `nats-mgr-`), so `get pods` shows no credential. No new Secret and no RBAC change is needed. A persistent-Redis restore no longer puts the Dragonfly admin password on a command line or in the apiserver's exec audit record either. A helper whose credential Secret or key is missing fails within 15 seconds with the kubelet's reason. (WI-383) ### Migration - Before upgrading to platform-stack 0.2.80, run `apprafter backup status` with apprafter 0.2.77 (older CLIs show such a Job as Running). A backup or check Job it shows as "Pending, cannot be scheduled" was created by 0.2.79, whose Jobs have no deadline: it stays Pending until someone deletes it, and under `concurrencyPolicy: Forbid` its CronJob starts nothing else, not even after the upgrade. Delete it with the `kubectl delete job` command `backup status` prints. (WI-386) ## platform-stack 0.2.79 / operator v0.2.51 — 2026-09-22 ### Added - Added a project-local `.mcp.json` registering the ATM MCP server, so a fresh checkout picks up the roadmap/claims connection without per-developer setup. The bearer is an `${ATM_TOKEN}` reference, not a literal — the credential itself stays in the gitignored `.claude/settings.local.json`, and the ATM discipline hooks (`.claude/`) remain local-only by repository convention. (WI-344) ### Changed - The pre-commit hook now runs the version and invariant gates that previously existed only in `just lint` — six version-coherence checks (operator/CLI/backup-runner/cue-cmp bumps, the cue-cmp plugin mirror, and cross-stream pin coherence) plus five cheap invariant checks. No workflow invokes `just lint`, so until now a commit could change operator code without moving `appVersion`, and `release-operator`'s two-axis gate would silently skip the rebuild so the change never shipped. Every gate is glob-scoped, so it costs nothing on a commit that cannot break it, and none can break an offline commit: each asks `git ls-remote` whether its tag is already published and treats a failed call as "bump in flight". `cargo fmt`/`clippy` and the docsgen property tests deliberately stay out of the hook. (WI-345) - `CHANGELOG.md` at the repository root is now the canonical changelog, tracked and self-describing. The root markdown allowlist in `.gitignore` had no entry for it, so the file ATM's changelog sync creates server-side would have been invisible in every local checkout. Its header now records what a heading names: this is a monorepo, so a heading lists **the streams a release actually moved** (`## cli v0.2.75 — …`, `## platform-stack 0.2.77 / cli v0.2.74 — …`), which is the repository's own long-standing convention and the reason `release-cli.yml` can find a release's notes by git tag alone. Only two of the four hand-edited numbers are actually chosen — the chart train and the CLI; the operator and cue-cmp versions cannot move without forcing a chart bump. That note lives outside any version section so regenerating a release does not wipe it. Everything from before ATM is frozen in `docs/changelog/history.md`, renamed from `UNRELEASED.md` because 237 of its sections describe already-shipped work; `docs/changelog/plan-history.md` is deleted, its role taken over by ATM. (WI-346) ### Fixed - An application with fewer than four replicas now rolls out with `maxSurge: 0, maxUnavailable: 1` instead of the Kubernetes default. The default resolves to `maxSurge: 1, maxUnavailable: 0` at one, two and three replicas, which forces a rollout to acquire capacity before it may release any — on a node at its allocatable ceiling the replacement pod stays `Pending`, nothing is released, and the rollout deadlocks silently while the old pods keep serving. From four replicas upward the Kubernetes default already releases before it acquires and is left alone. At exactly one replica the old pod now terminates before its replacement is ready, so there is a brief window with nothing serving — accepted deliberately, since a single replica on a single node has no availability guarantee to lose. Applications mounting their own disk are unaffected: they already roll with `Recreate`. (WI-279) - `release-cli.yml` no longer publishes a release with the wrong notes, or with none. Two defects, both found by reading rather than by a failure — and both permanent when they bite, because the workflow runs once per tag and never regenerates an older release. (1) The section lookup was a substring test, so tag `v0.2.5` matched inside the heading `## cli v0.2.51 — …`; verified reproducible, and hidden until now only because the file is newest-first so the intended section happened to come first. The match is now anchored on both sides, the tag's dots are escaped so they cannot act as regex wildcards, and only the part of the heading before the dash is searched — prose after it mentions other versions. (2) A missing section fell back to a stub body and exited 0, publishing a green release that describes nothing; it now fails loudly. The lookup reads the canonical root `CHANGELOG.md` first and falls back to `docs/changelog/history.md` for tags cut before the switch. (WI-347) - `release-cli.yml` is parseable again, and a new `scripts/check-workflows.sh` gate (actionlint, wired into `just lint` and pre-commit) keeps it that way. A shell comment inside a `run:` block had contained empty GitHub Actions expression delimiters: Actions expands those before the shell sees the script and does not know the line is a comment, so an empty expression made the whole workflow unparseable. The failure mode is why this needs a gate rather than a fix — an unparseable workflow fails as a run with **zero jobs**, which `gh pr checks` does not list, so every pull-request check stays green while a release workflow is dead until the day it should have run. (WI-348)