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:
`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
backup — measured per app: six of seven catalog apps keep the login in their data (code-server is the exception,
`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.
## 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)
}
}
// 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 {
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) {
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
// only for a unit that holds no data file at all, whose whole content is its definition.
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))
if err != nil {
return time.Time{}, false
return time.Time{}, false, false
}
if man := readManifest(UnitManifestFile(unitDir)); man != nil {
if t, ok := man.Data.DataTime(); ok {
return t, true
return t, true, true
}
}
var newest time.Time
newest = newestDataFile(UnitDBDumpDir(unitDir), ".sql", newest)
newest = newestDataFile(UnitVolumeDumpDir(unitDir), ".tar", newest)
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
+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
Tier int `json:"tier"` // always 1 (see above)
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"),
@@ -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.
newest, ok := unitNewestArtifact(RecoveryUnitPath(nsRoot, stackName))
newest, proven, ok := unitDataTime(RecoveryUnitPath(nsRoot, stackName))
if !ok {
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,
Tier: 1,
DriveLabel: m.sysDriveLabelFor(stackName),
DataProven: proven,
}}, 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.
// RFC3339 UTC; "" when the unit is not readable.
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.
@@ -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
// record is what makes this date impossible to overstate.
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.UnitDataUnproven = !proven
}
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
// the data the copy holds was written, which a mirror run copies but never makes newer. "" = unknown.
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.
@@ -58,6 +61,7 @@ func restorePointFromCoverage(cov Tier2Coverage) Tier2RestorePoint {
PackagePreserved: preserved,
CopyLastSuccess: cov.CopyLastSuccess,
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
// the package date is the honest one, because that is what the copy really holds.
func (p Tier2RestorePoint) ProvenCopyTime() (time.Time, bool) {
if !p.Restorable || !p.CopyDateProven {
if !p.Restorable || !p.CopyDateProven || p.DataUnproven {
return time.Time{}, false
}
src := p.CopyLastSuccess
@@ -269,6 +273,11 @@ func (m *Manager) updateTierPoint(ctx context.Context, stackName string, tier in
}
pts, _ := list(stackName)
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 {
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) {
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 }