R-351: the restore compares where the backup says the data lived; second press cannot start a second run
gates / gates (push) Successful in 10s

Part 3 (not droppable) and the engine half of Part 2. No version bump yet - one bump and
one bake at the end of the session.

PART 3a - a second press really did start a second run. Established with a test BEFORE any
change: both offboxReconstituteHandler and offboxPlaceHandler answered "...elindult" and
overwrote the first restore's op/stack. Cause: every restore handler gated on
backupMgr.IsRunning() - the CONCURRENCY flag, which the restore goroutine acquires AFTER the
handler returns (offbox_reconstitute.go:180, offbox_restore.go:393). Seven sites. The wizard
had read the correct flag since v0.154.0 and said so in a comment; the handlers never moved.
New Server.restoreOpBlocked() reads BOTH flags - the display flag covers the whole off-box
restore, the concurrency flag is the only one the nightly backup holds - and the refusal now
names the running app and a route.

PART 3b - the page DOES refresh; the defect was the RESULT. backups_shared.html gated the
terminal result on a page-local sawRunning flag, so a restore that finished before the page
was opened, or inside one 3s poll, was shown to nobody. The 2026-08-21 OpenGist restore took
8.666s and no screen ever said it completed - the answer existed only in docker logs.
RestoreOpStatus.LastRecent now carries the server's verdict. The 10-minute window moved to
internal/backup as RestoreResultWindow and internal/web's constant is an alias: one
expression, two surfaces. Also removed the wizard's self-contradiction, which said the state
refreshes automatically AND that you must refresh the page.

PART 2 (engine) - every recovery unit manifest has carried drive and namespace_root since
schema 1, and NO non-test code read either back. The reconstitution opened the manifest and
took only the coherence stamp, then resolved its destination from the live app. A restore
into a different destination succeeded silently under a green message. New
backup/offbox_placement.go: CheckPlacement (pure, total), PlacementMismatchMessage,
recordedPlacementFromScratch. Compared before the safety dump and before the first byte.
A mismatch is NAMED and refused; ackPlacementChange lets the customer proceed deliberately -
a separate field from confirm=1, because one click must not carry two decisions. An UNKNOWN
recording is never a mismatch: refusing on an absence would strand every pre-field unit.
The not-installed refusal (R-253) now names the drive the backup recorded.

RED-PROOFS, each mutation asserted applied and reverted to 0:
  B  both guards removed (count asserted 2) -> the restore WAS seen starting with no drive
     attached: no error, full 3.00s run, wrote into /tmp/mutant-destination
  C  Mismatch forced false -> the silent divergent restore returned
  E  Known() forced true  -> the fabricated empty prefill appeared
  D  Mismatch forced true -> 8 ordinary reconstitute tests broke, proving reachability both ways
Note on D: the existing fixtures write a schema-1 manifest with NO drive, so they are
scenario-E shaped. The matching case is covered in the scenario table, not by them.

Gates 11/11 OK. Suite 28 packages ok. Hungarian verified as hex, no BOM, no mojibake sentinels.

NOT in this commit, still open: Part 2's scenario-A prefill UI, Part 1's deploy-page
visibility line, Part 1's specification document, Part 4's measurement.
This commit is contained in:
2026-08-21 21:04:16 +02:00
parent 2fa1efc5e5
commit 985388c6e9
14 changed files with 796 additions and 30 deletions
+15 -11
View File
@@ -352,10 +352,10 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
}
// Fast-path refuse a concurrent op, then run async on a BACKGROUND context (a proxy read-timeout on
// r.Context() would CANCEL the SFTP restore mid-flight — the F4 lesson).
if s.backupMgr.IsRunning() {
if msg, blocked := s.restoreOpBlocked(); blocked {
// Same silence class as the size gate: a refusal that starts nothing must still be findable.
s.logger.Printf("[WARN] [web] off-box restore refused for %s (mode=%s): another backup/restore op is already running", app, mode)
offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true)
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
return
}
full := mode == "full"
@@ -433,15 +433,19 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", true)
return
}
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true)
if msg, blocked := s.restoreOpBlocked(); blocked {
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
return
}
// R-351: a SEPARATE field from `confirm`. The restore's own confirm answers "overwrite my live
// data"; this one answers "yes, into a different place than the backup recorded". One checkbox
// carrying both would be the two-decisions-one-button shape R-48 removed from this surface.
ackPlacement := r.FormValue("ack_placement") == "1"
s.backupMgr.BeginRestoreOp("offbox-reconstitute", app)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
res, err := s.backupMgr.ReconstituteFromOffsite(ctx, app)
res, err := s.backupMgr.ReconstituteFromOffsite(ctx, app, ackPlacement)
if err != nil {
s.logger.Printf("[ERROR] [web] off-box reconstitute %s (async): %v", app, err)
s.backupMgr.EndRestoreOp(false, "A teljes visszaállítás sikertelen: "+err.Error())
@@ -521,8 +525,8 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
return
}
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true)
if msg, blocked := s.restoreOpBlocked(); blocked {
offboxRedirectTo(w, r, restoreWizardPath(app), msg, true)
return
}
s.backupMgr.BeginRestoreOp("offbox-place", app)
@@ -554,8 +558,8 @@ func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
return
}
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
if msg, blocked := s.restoreOpBlocked(); blocked {
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
s.backupMgr.BeginRestoreOp("shares-restore", backup.SharesDisplayName)
@@ -580,8 +584,8 @@ func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
return
}
if s.backupMgr.IsRunning() {
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
if msg, blocked := s.restoreOpBlocked(); blocked {
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
s.backupMgr.BeginRestoreOp("shares-place", backup.SharesDisplayName)