v0.235.0: a delivered fix must also refresh the stored definition
gates / gates (push) Successful in 14s

Found by the LIVE validation on demo-hp, not by review. Scenario A passed - a
non-image catalog change reached the pinned app on the real 15-minute cycle - and
that is exactly what exposed the gap: the stored applied-compose.yml is written
when the PIN is written, so the fix landed in the live compose file and not in the
store. The first time the catalog then moved a version, the freeze would have
rendered the pre-fix definition and reverted every fix delivered since - silently
undoing the half of the operator's ruling that says fixes keep flowing.

The equal-images branch now refreshes the store as it delivers. The images cannot
move in that branch by construction, so no version moves and no intent is
rewritten. RenderPlan gains StackDir so the syncer can write it.

TestFixRefreshesTheStoredDefinition asserts both halves: the fix reaches the
store, and it survives the freeze that follows.
This commit is contained in:
2026-09-06 10:04:21 +02:00
parent 8a0e0a59ad
commit 2a56f557d0
4 changed files with 95 additions and 13 deletions
+29 -12
View File
@@ -377,12 +377,13 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
// v0.235.0 — the RENDER. `.felhom.yml` is always copied verbatim (it holds no image);
// only the compose file can be frozen. See renderSource for the whole table.
refreshAppliedIn := ""
if filename == "docker-compose.yml" {
frozenSrc, skip := s.renderSource(appName, src)
renderedSrc, skip, refresh := s.renderSource(appName, src)
if skip {
continue
}
src = frozenSrc
src, refreshAppliedIn = renderedSrc, refresh
}
changed, err := copyIfChanged(src, dst)
@@ -393,6 +394,21 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
if changed {
anyChanged = true
s.logger.Printf("[INFO] [sync] Updated %s/%s", appName, filename)
// The fix we just delivered becomes part of what this app is pinned TO. Without
// this the stored definition stays as it was when the pin was written, and the
// first time the catalog moves the freeze reverts every fix delivered since —
// silently undoing the half of the ruling that says fixes keep flowing.
// The IMAGES are unchanged here by construction (this branch only runs when the
// catalog's images equal the pin), so no version moves and no intent is rewritten.
if refreshAppliedIn != "" {
if data, rerr := os.ReadFile(src); rerr != nil {
s.logger.Printf("[WARN] [sync] %s: could not re-read the template to refresh its stored definition: %v", appName, rerr)
} else if serr := stacks.StoreAppliedDefinition(refreshAppliedIn, data); serr != nil {
s.logger.Printf("[WARN] [sync] %s: could not refresh the stored definition: %v", appName, serr)
} else if s.isDebug() {
s.logger.Printf("[DEBUG] [sync] %s: stored definition refreshed with the delivered fix", appName)
}
}
if s.isDebug() {
s.logFileHashes(appName, filename, src, dst)
}
@@ -438,27 +454,27 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error)
// deploy fields reaching a deployed app, and it destroys the self-healing measured in
// SPIKE-app-update-2026-09-01 §3 — a hand-broken compose file repaired itself within 15 minutes.
// Both halves were worth keeping; only the version change was not.
func (s *Syncer) renderSource(appName, catalogSrc string) (src string, skip bool) {
func (s *Syncer) renderSource(appName, catalogSrc string) (src string, skip bool, refreshAppliedIn string) {
if s.renderPlanFn == nil {
return catalogSrc, false // pre-v0.235.0 behaviour, byte for byte
return catalogSrc, false, "" // pre-v0.235.0 behaviour, byte for byte
}
plan := s.renderPlanFn(appName)
if !plan.Deployed || plan.Protected {
return catalogSrc, false
return catalogSrc, false, ""
}
if plan.Deploying {
// Do not race an in-flight deploy for its own compose file.
if s.isDebug() {
s.logger.Printf("[DEBUG] [sync] %s: mid-deploy, compose file left alone this cycle", appName)
}
return "", true
return "", true, ""
}
if len(plan.Pinned) == 0 {
if s.isDebug() {
s.logger.Printf("[DEBUG] [sync] %s: deployed but UNPINNED — catalog copied verbatim (pre-v0.235.0 behaviour)", appName)
}
return catalogSrc, false
return catalogSrc, false, ""
}
catalogImages, err := stacks.ParseComposeImages(catalogSrc)
@@ -466,19 +482,20 @@ func (s *Syncer) renderSource(appName, catalogSrc string) (src string, skip bool
// CANNOT TELL whether the catalog has moved. Leave the app's file alone rather than guess in
// either direction — an unreadable catalog template must not be able to unfreeze an app.
s.logger.Printf("[WARN] [sync] %s: cannot read the catalog template's images (%v) — compose file left alone this cycle", appName, err)
return "", true
return "", true, ""
}
if samePin(plan.Pinned, catalogImages) {
// The catalog still offers what this app runs: everything else in the template is a FIX and
// is delivered, exactly as before v0.235.0. This branch is why the feature is not a freeze.
return catalogSrc, false
// The delivered fix also REFRESHES the stored definition — see the call site.
return catalogSrc, false, plan.StackDir
}
if plan.AppliedPath == "" {
// We cannot freeze what we do not have, and we must not invent it.
s.logger.Printf("[WARN] [sync] %s: the catalog has moved past this app's pinned version, but no stored definition exists — copying the catalog verbatim (pre-v0.235.0 behaviour). The app will take the new version on its next start.", appName)
return catalogSrc, false
return catalogSrc, false, ""
}
// DEFENCE IN DEPTH, and this line was added because a test demanded it: the manager's
// RenderPlanFor already refuses to hand over a path whose file is missing or empty, but the
@@ -486,12 +503,12 @@ func (s *Syncer) renderSource(appName, catalogSrc string) (src string, skip bool
// The cost of re-reading a small file once per app per cycle is nothing next to that.
if data, err := os.ReadFile(plan.AppliedPath); err != nil || len(strings.TrimSpace(string(data))) == 0 {
s.logger.Printf("[WARN] [sync] %s: the stored definition is missing or empty (%v) — copying the catalog verbatim rather than writing an empty compose file", appName, err)
return catalogSrc, false
return catalogSrc, false, ""
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [sync] %s: catalog has moved past the pin — rendering the stored applied definition", appName)
}
return plan.AppliedPath, false
return plan.AppliedPath, false, ""
}
// samePin compares a pin against a template's images. Local to the syncer so this package needs