R-699: a unit that holds no data is never an update-precondition copy (found live on 9202)
gates / gates (push) Successful in 25s

A just-installed app's unit, captured by the status refresh before any backup, satisfied
the precondition on its manifest time; tandoor's PostgreSQL was converted with no backup
of its database. Listed still; never a copy on Tier 1 or Tier 2. Red-proof RP6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-27 12:04:03 +02:00
parent b6810f14ff
commit 7fcda8f1a4
8 changed files with 89 additions and 11 deletions
+7 -2
View File
@@ -1,4 +1,4 @@
## v0.275.0 — a backup's data and its version travel together (R-696, `07` §6.6, D4 option A); R-695, R-691, R-694 (2026-09-26) ## v0.275.0 — a backup's data and its version travel together (R-696, `07` §6.6, D4 option A); R-695, R-691, R-694, R-699 (2026-09-26)
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: yes (hu + en, 5 keys). Evidence: **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: yes (hu + en, 5 keys). Evidence:
`felhom.eu/documentation/audits/version-travel-2026-09-26/`. `felhom.eu/documentation/audits/version-travel-2026-09-26/`.
@@ -33,7 +33,12 @@
page no longer shows that value as "the first password set at install" and says to use the password valid at the page no longer shows that value as "the first password set at install" and says to use the password valid at the
backup — measured per app: six of seven catalog apps keep the login in their data (code-server is the exception, backup — measured per app: six of seven catalog apps keep the login in their data (code-server is the exception,
`loginAppliedEveryStart`). `loginAppliedEveryStart`).
- Red-proofs (each seen failing, tree restored): `A5-redproofs/` RP1–RP5, `D2/`, `D3/`, `D4/`. Parity: one new - **R-699 (found live on 9202 during Part B):** a unit that is only a captured definition — a just-installed app's,
written by the status refresh before any backup — satisfied the update's precondition ("Tier 1 copy 2m0s old") and
tandoor's PostgreSQL was converted with no backup of its database. Such a unit stays listed (restorable as the
definition) but is never a precondition copy on Tier 1 or Tier 2 (`RestorePoint.DataProven`, `unitDataTime`); the
update then backs up first.
- Red-proofs (each seen failing, tree restored): `A5-redproofs/` RP1–RP6, `D2/`, `D3/`, `D4/`. Parity: one new
Hungarian fixture (`deploy_deployed_restored_login`); no existing fixture changed. Hungarian fixture (`deploy_deployed_restored_login`); no existing fixture changed.
## v0.274.0 — kept data: a choice at reinstall, a list, a read-only view, a load (2026-09-25, `09` §3 decision 36); R-690, R-692 ## v0.274.0 — kept data: a choice at reinstall, a list, a read-only view, a load (2026-09-25, `09` §3 decision 36); R-690, R-692
@@ -574,3 +574,47 @@ func TestA4_TheOffsiteRunRecordsThePushedDataTime(t *testing.T) {
t.Fatalf("after a successful push the data-time record is %+v ok=%v, want the unit's data time", rec, ok) t.Fatalf("after a successful push the data-time record is %+v ok=%v, want the unit's data time", rec, ok)
} }
} }
// R-699 (v0.275.0) — a just-installed app's unit, captured by the status refresh before any backup ran,
// holds only the definition. It stays LISTED (restorable as the definition) but is never a copy the
// update's precondition leans on; after a data run it is.
//
// COMPANION RED-PROOF (REPORT.md): drop the DataProven skip in updateTierPoint — the precondition then
// accepts the dump-less unit ("Tier 1 … 0s old"), which is what 9202 logged for tandoor on 2026-09-27.
func TestR699_ADefinitionOnlyUnitIsNotAPreconditionCopy(t *testing.T) {
v := newVTStack(t, "16")
if err := v.m.captureRecoveryUnit("app", false); err != nil { // the refresh, right after the install
t.Fatal(err)
}
pts, _ := v.m.ListRestorePoints("app")
if len(pts) != 1 || pts[0].DataProven {
t.Fatalf("points = %+v, want one listed, NOT proven", pts)
}
any := func(UpdateTierPoint) bool { return true }
if p, ok, _ := v.m.UpdateRestorePoints(context.Background(), "app", any); ok {
t.Fatalf("the precondition accepted tier %d at %s — a unit with no data is not a copy", p.Tier, p.At)
}
// The update's own "back up first" is a data run: afterwards the unit IS a copy.
v.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "app", ContainerName: "app-db", DBType: DBTypePostgres}}, nil
}
v.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult {
p := filepath.Join(dir, "app-postgres.sql")
mustWrite(t, p, pgDump(1))
return DumpResult{DB: db, FilePath: p}
}
v.m.perAppTier2 = func(string) error { return nil }
if err := v.m.RunAppBackupNow(context.Background(), "app"); err != nil {
t.Fatal(err)
}
if _, ok, _ := v.m.UpdateRestorePoints(context.Background(), "app", any); !ok {
t.Fatal("after a backup the own unit is still not a copy")
}
}
func TestR699_ADefinitionOnlyMirrorIsNotAPreconditionCopy(t *testing.T) {
p := Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: "2026-09-27T09:36:36Z", DataDate: "2026-09-27T09:36:36Z", DataUnproven: true}
if _, ok := p.ProvenCopyTime(); ok {
t.Fatal("a mirror of a definition-only unit counted as a proven copy")
}
}
@@ -41,7 +41,7 @@ func r659Manager(t *testing.T, files bool, tiers map[int]time.Time) *Manager {
if !ok { if !ok {
return nil, false return nil, false
} }
return []RestorePoint{{Time: at.Format(time.RFC3339), Tier: 1}}, true return []RestorePoint{{Time: at.Format(time.RFC3339), Tier: 1, DataProven: true}}, true
} }
m.updateOffsiteTimesFn = func(context.Context) (map[string]time.Time, error) { m.updateOffsiteTimesFn = func(context.Context) (map[string]time.Time, error) {
at, ok := tiers[UpdateTierOffsite] at, ok := tiers[UpdateTierOffsite]
+15 -4
View File
@@ -77,22 +77,33 @@ func (m *Manager) driveLabelForRoot(root string) string {
// copies `pre-restore-*` excluded — an update's own safety dump is not a backup); the manifest's time // copies `pre-restore-*` excluded — an update's own safety dump is not a backup); the manifest's time
// only for a unit that holds no data file at all, whose whole content is its definition. // only for a unit that holds no data file at all, whose whole content is its definition.
func unitNewestArtifact(unitDir string) (time.Time, bool) { func unitNewestArtifact(unitDir string) (time.Time, bool) {
t, _, ok := unitDataTime(unitDir)
return t, ok
}
// unitDataTime is unitNewestArtifact plus WHETHER THE TIME IS A PROVEN DATA TIME (R-699, v0.275.0): true
// when a data run confirmed the unit (`data` block) or it holds data files; false when the unit is only a
// captured definition — a just-installed app's unit, written by the status refresh before any backup
// ran. Such a unit is still LISTED (it can be restored: it is the app's definition), but it is never a
// copy the update's precondition may lean on — measured on 9202 2026-09-27: tandoor's two-minute-old,
// dump-less unit satisfied it and PostgreSQL was converted with no backup of the database.
func unitDataTime(unitDir string) (time.Time, bool, bool) {
fi, err := os.Stat(UnitManifestFile(unitDir)) fi, err := os.Stat(UnitManifestFile(unitDir))
if err != nil { if err != nil {
return time.Time{}, false return time.Time{}, false, false
} }
if man := readManifest(UnitManifestFile(unitDir)); man != nil { if man := readManifest(UnitManifestFile(unitDir)); man != nil {
if t, ok := man.Data.DataTime(); ok { if t, ok := man.Data.DataTime(); ok {
return t, true return t, true, true
} }
} }
var newest time.Time var newest time.Time
newest = newestDataFile(UnitDBDumpDir(unitDir), ".sql", newest) newest = newestDataFile(UnitDBDumpDir(unitDir), ".sql", newest)
newest = newestDataFile(UnitVolumeDumpDir(unitDir), ".tar", newest) newest = newestDataFile(UnitVolumeDumpDir(unitDir), ".tar", newest)
if newest.IsZero() { if newest.IsZero() {
return fi.ModTime(), true return fi.ModTime(), false, true
} }
return newest, true return newest, true, true
} }
// ListRemovedAppUnits walks backups/primary/ on every connected registered drive and returns the // ListRemovedAppUnits walks backups/primary/ on every connected registered drive and returns the
+6 -1
View File
@@ -21,6 +21,10 @@ type RestorePoint struct {
ShortID string `json:"short_id"` // opaque label; POST /backup/restore uses it for logging only ShortID string `json:"short_id"` // opaque label; POST /backup/restore uses it for logging only
Tier int `json:"tier"` // always 1 (see above) Tier int `json:"tier"` // always 1 (see above)
DriveLabel string `json:"drive_label"` // registered storage label; empty for the SSD fallback DriveLabel string `json:"drive_label"` // registered storage label; empty for the SSD fallback
// DataProven (R-699, v0.275.0) — the time is a PROVEN data time (a data run confirmed the unit, or it
// holds data files). False for a unit that is only a captured definition: listed, never a copy the
// update's precondition may lean on. Not part of the page payload.
DataProven bool `json:"-"`
} }
// restorePointShortID is the single keep-side restore point's identifier. Hungarian ("local"), // restorePointShortID is the single keep-side restore point's identifier. Hungarian ("local"),
@@ -60,7 +64,7 @@ func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, fo
} }
// v0.275.0 (R-696): the unit's DATA time (unitNewestArtifact), never the manifest's refresh time. // v0.275.0 (R-696): the unit's DATA time (unitNewestArtifact), never the manifest's refresh time.
newest, ok := unitNewestArtifact(RecoveryUnitPath(nsRoot, stackName)) newest, proven, ok := unitDataTime(RecoveryUnitPath(nsRoot, stackName))
if !ok { if !ok {
return []RestorePoint{}, true // no recovery unit yet — "no backup" is a valid answer return []RestorePoint{}, true // no recovery unit yet — "no backup" is a valid answer
} }
@@ -70,6 +74,7 @@ func (m *Manager) ListRestorePoints(stackName string) (points []RestorePoint, fo
ShortID: restorePointShortID, ShortID: restorePointShortID,
Tier: 1, Tier: 1,
DriveLabel: m.sysDriveLabelFor(stackName), DriveLabel: m.sysDriveLabelFor(stackName),
DataProven: proven,
}}, true }}, true
} }
+5 -1
View File
@@ -100,6 +100,9 @@ type Tier2Coverage struct {
// demo-hp a copy holding a dump written at 00:30Z was dated by a manifest from the day before. // demo-hp a copy holding a dump written at 00:30Z was dated by a manifest from the day before.
// RFC3339 UTC; "" when the unit is not readable. // RFC3339 UTC; "" when the unit is not readable.
UnitDataDate string UnitDataDate string
// UnitDataUnproven (R-699, v0.275.0) — the mirrored unit holds no proven data (no `data` block, no
// data file): restorable as a definition, never a copy the update's precondition may lean on.
UnitDataUnproven bool
} }
// CanRestore reports whether the FILE restore has any subtree to read at all. // CanRestore reports whether the FILE restore has any subtree to read at all.
@@ -148,8 +151,9 @@ func tier2CoverageAt(destBase string) Tier2Coverage {
// R-403: ask the package itself when it was made. Reading the artifact rather than the status // R-403: ask the package itself when it was made. Reading the artifact rather than the status
// record is what makes this date impossible to overstate. // record is what makes this date impossible to overstate.
c.UnitPackageDate = unitPackageDate(unitDir) c.UnitPackageDate = unitPackageDate(unitDir)
if newest, ok := unitNewestArtifact(unitDir); ok { if newest, proven, ok := unitDataTime(unitDir); ok {
c.UnitDataDate = newest.UTC().Format(time.RFC3339) c.UnitDataDate = newest.UTC().Format(time.RFC3339)
c.UnitDataUnproven = !proven
} }
return c return c
} }
+10 -1
View File
@@ -46,6 +46,9 @@ type Tier2RestorePoint struct {
// DataDate (v0.275.0, R-696) — the mirrored unit's DATA time (unitNewestArtifact on the mirror): when // DataDate (v0.275.0, R-696) — the mirrored unit's DATA time (unitNewestArtifact on the mirror): when
// the data the copy holds was written, which a mirror run copies but never makes newer. "" = unknown. // the data the copy holds was written, which a mirror run copies but never makes newer. "" = unknown.
DataDate string DataDate string
// DataUnproven (R-699) — the mirrored unit is only a captured definition (no `data`, no data file):
// never a copy the update may lean on.
DataUnproven bool
} }
// restorePointFromCoverage is the pure half of the predicate. // restorePointFromCoverage is the pure half of the predicate.
@@ -58,6 +61,7 @@ func restorePointFromCoverage(cov Tier2Coverage) Tier2RestorePoint {
PackagePreserved: preserved, PackagePreserved: preserved,
CopyLastSuccess: cov.CopyLastSuccess, CopyLastSuccess: cov.CopyLastSuccess,
DataDate: cov.UnitDataDate, DataDate: cov.UnitDataDate,
DataUnproven: cov.UnitDataUnproven,
} }
} }
@@ -86,7 +90,7 @@ func (m *Manager) Tier2UnitRestorePoint(stackName string) (Tier2RestorePoint, er
// actually mirrored the unit — EXCEPT when the run preserved an older package (R-403), in which case // actually mirrored the unit — EXCEPT when the run preserved an older package (R-403), in which case
// the package date is the honest one, because that is what the copy really holds. // the package date is the honest one, because that is what the copy really holds.
func (p Tier2RestorePoint) ProvenCopyTime() (time.Time, bool) { func (p Tier2RestorePoint) ProvenCopyTime() (time.Time, bool) {
if !p.Restorable || !p.CopyDateProven { if !p.Restorable || !p.CopyDateProven || p.DataUnproven {
return time.Time{}, false return time.Time{}, false
} }
src := p.CopyLastSuccess src := p.CopyLastSuccess
@@ -269,6 +273,11 @@ func (m *Manager) updateTierPoint(ctx context.Context, stackName string, tier in
} }
pts, _ := list(stackName) pts, _ := list(stackName)
for _, rp := range pts { for _, rp := range pts {
if !rp.DataProven {
// R-699: a unit that is only a captured definition is not a copy of the app's data.
m.logger.Printf("[INFO] [backup] update precondition for %s: the own unit holds no data yet (no backup run has confirmed it) — not a copy", stackName)
continue
}
if at, err := time.Parse(time.RFC3339, rp.Time); err == nil { if at, err := time.Parse(time.RFC3339, rp.Time); err == nil {
return UpdateTierPoint{Tier: tier, At: at}, true return UpdateTierPoint{Tier: tier, At: at}, true
} }
@@ -39,7 +39,7 @@ func noTier2(string) (Tier2RestorePoint, error) {
} }
func tier1At(at time.Time) func(string) ([]RestorePoint, bool) { func tier1At(at time.Time) func(string) ([]RestorePoint, bool) {
return func(string) ([]RestorePoint, bool) { return func(string) ([]RestorePoint, bool) {
return []RestorePoint{{Time: at.UTC().Format(time.RFC3339), ShortID: "helyi", Tier: 1}}, true return []RestorePoint{{Time: at.UTC().Format(time.RFC3339), ShortID: "helyi", Tier: 1, DataProven: true}}, true
} }
} }
func noTier1(string) ([]RestorePoint, bool) { return []RestorePoint{}, true } func noTier1(string) ([]RestorePoint, bool) { return []RestorePoint{}, true }