carlos / platform Public

offline reports: the 60-s-ahead filter applies to self-serve moves only (final review F15)

ValidateOfflineReportFor drops a request-bound report dated more than
OfflineRequestSkew ahead only when the move carries a self_serve block; an
operator move's reader keeps it, as the base reader did, so the operator
status answer stays byte for byte with the switch off.

Witness: TestLegacyStagedReportAheadIsKept, whose golden was written by the
same test run against the base e8087b49.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 3e5d6a830fda2b6ed48b19d389447ad95c4d699c parent e43e415
3 files changed, +67 −3
  • internal/console/apiinstancemove_offline_ahead_test.go +62 −0
  • internal/console/testdata/operator-staged-ahead.golden +1 −0
  • internal/moves/offline.go +4 −3
diff --git a/internal/console/apiinstancemove_offline_ahead_test.go b/internal/console/apiinstancemove_offline_ahead_test.go
new file mode 100644
index 000000000..49dac9f9e
--- /dev/null
+++ b/internal/console/apiinstancemove_offline_ahead_test.go
@@ -0,0 +1,62 @@
+package console
+
+import (
+ "net/http"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/moves"
+)
+
+// operatorStagedAheadGolden is the operator status door's answer for a
+// staged operator move whose stager report is dated 61 s ahead, written by
+// TestLegacyStagedReportAheadIsKept run against the base before plan 2
+// (e8087b49) with CARLOS_OPERATOR_SNAPSHOT_WRITE=1 and the self_serve
+// check removed (the base has no such field). Never rewritten from a later
+// tree.
+const operatorStagedAheadGolden = "testdata/operator-staged-ahead.golden"
+
+// TestLegacyStagedReportAheadIsKept (final review F15): the request-bound
+// report filter (a report more than moves.OfflineRequestSkew in the future
+// is dropped) is the self-serve contract. On an operator move (no
+// self_serve block) the status door keeps a stager report dated 61 s ahead
+// as the base reader did, byte for byte.
+func TestLegacyStagedReportAheadIsKept(t *testing.T) {
+ w := newOfflineWorld(t, oneEach...)
+ w.stage()
+ if rec := w.record(); rec.Move.SelfServe != nil {
+ t.Fatal("the operator stage wrote a self_serve block")
+ }
+ at := time.Now().UTC().Add(61 * time.Second).Truncate(time.Second)
+ w.staged("ie-box", func(r *moves.OfflineReport) { r.At = at })
+ code, out := w.req(http.MethodGet, "", "")
+ if code != http.StatusOK {
+ t.Fatalf("GET status = %d %s", code, out)
+ }
+ got := normaliseAheadAnswer(out)
+ if os.Getenv("CARLOS_OPERATOR_SNAPSHOT_WRITE") == "1" {
+ if err := os.WriteFile(operatorStagedAheadGolden, []byte(got+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return
+ }
+ want, err := os.ReadFile(operatorStagedAheadGolden)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != strings.TrimSuffix(string(want), "\n") {
+ t.Fatalf("a legacy stager report 61 s ahead changed the operator's answer:\n got %s\n want %s", got, want)
+ }
+}
+
+// normaliseAheadAnswer blanks what differs between two runs: ids, stamps
+// and times.
+func normaliseAheadAnswer(s string) string {
+ s = regexp.MustCompile(`mv_[a-z0-9]+`).ReplaceAllString(s, "mv_X")
+ s = regexp.MustCompile(`"[0-9]{4}-[0-9]{2}-[0-9]{2}T[^"]*"`).ReplaceAllString(s, `"T"`)
+ s = regexp.MustCompile(`:[0-9]{9,}`).ReplaceAllString(s, ":N")
+ return strings.TrimSpace(s)
+}
diff --git a/internal/console/testdata/operator-staged-ahead.golden b/internal/console/testdata/operator-staged-ahead.golden
new file mode 100644
index 000000000..107876494
--- /dev/null
+++ b/internal/console/testdata/operator-staged-ahead.golden
@@ -0,0 +1 @@
+{"host":"acme.shop.example.com","state":"offline-staging","id":"mv_X","from":"ie","to":"uk","stager":"ie-box","requested_by":"alice@keymail.dev","requested_at":N,"updated_at":N,"next":"start","can_abort":true,"never_offline":true,"staged_at":N,"stage":{"box":"ie-box","phase":"staged","at":N,"replica_objects":305,"replica_bytes":N,"staged_objects":300,"staged_bytes":N,"round_objects":12,"round_bytes":9000000},"reports":[{"v":1,"op":"mv_X","host":"acme.shop.example.com","box":"ie-box","role":"stager","phase":"staged","at":"T","held":false,"fenced":false,"quiesced":false,"local_db":false,"files":false,"env":false,"sidecar":false,"row_present":false,"replica_objects":305,"replica_bytes":N,"staged_objects":300,"staged_bytes":N,"round_objects":12,"round_bytes":9000000,"fresh":false}],"items":[{"id":"db","item":"SQLite DB at its final position","disposition":"carried: instances/\u003chost\u003e/ is mirrored to the target's moves/\u003chost\u003e/ and promoted by the target's first wake after open","owner":"landing","source":"seal","status":"waiting"},{"id":"db-tail","item":"writes the source's stop failed to checkpoint","disposition":"carried: the sealer checkpoints first when a source report is ahead of the replica","owner":"seal","source":"seal","status":"waiting"},{"id":"build-state","item":"instances/\u003chost\u003e/build-state.json","disposition":"carried: inside the replica prefix, after CarryRequest","owner":"seal","source":"seal","status":"waiting"},{"id":"lease","item":"instances/\u003chost\u003e/lock.json","disposition":"not carried: the source's lock becomes the tombstone until finish; a stale tombstone of another move in the target bucket is lifted at landing","owner":"landing","source":"seal","status":"waiting"},{"id":"files","item":"flat \u003cDataDir\u003e/\u003chost\u003e/ and the nested instance directory minus platform artifacts","disposition":"carried: sealed archive at moves/@offline/\u003chost\u003e/\u003cid\u003e/files.sealed in the target bucket","owner":"landing","source":"seal","status":"waiting"},{"id":"env","item":"\u003chost\u003e.env: instance key, admin token and overrides","disposition":"carried: byte for byte inside the archive","owner":"landing","source":"seal","status":"waiting"},{"id":"key-adoption","item":"\u003chost\u003e.instance-key-adoption.json","disposition":"not carried: written only for claim launches; a copy on the target is parked","owner":"landing","status":"waiting"},{"id":"config","item":"\u003chost\u003e.config.env: the app's settings, store binding and secrets","disposition":"rebuilt: rendered on the target from the bucket bundle","owner":"landing","status":"waiting"},{"id":"env-pin","item":"the composed launch environment","disposition":"rebuilt: app settings must be equal on both rows; box settings are not compared","owner":"landing","source":"seal","status":"waiting"},{"id":"release","item":"the build the source runs","disposition":"rebuilt: not copied, ordinary adoption stages the channel's build for the ready row after open","owner":"landing","source":"seal","status":"waiting"},{"id":"store","item":"object store bucket and its credentials","disposition":"not carried: the bucket stays where it is and its binding travels in config, so env-pin covers it","owner":"none","status":"unchecked"},{"id":"timers","item":"timer rows, once-timers the app armed included","disposition":"carried: a timers snapshot beside the archive, replaced on the held row","owner":"landing","source":"seal","status":"waiting"},{"id":"next-due","item":"the row's NextDue","disposition":"carried: in the journal's source baselines","owner":"landing","source":"seal","status":"waiting"},{"id":"replication","item":"the row's replication rung","disposition":"carried: in the journal's source baselines","owner":"landing","source":"seal","status":"waiting"},{"id":"row","item":"the registry row","disposition":"rebuilt: reconcile mints it from the record, held at flat paths","owner":"landing","status":"waiting"},{"id":"routing","item":"edges file, backhaul, certificates, derived aliases","disposition":"not carried: produced by the existing passes once the row is ready, and the runbook confirms a certificate path before the move","owner":"none","status":"unchecked"},{"id":"source-retire","item":"everything the source box keeps for the host after open","disposition":"parked: renamed, never deleted, and listed path by path in the source's parked report","owner":"none","status":"unchecked"},{"id":"sidecar","item":"\u003chost\u003e-sidecar.env","disposition":"refused: a source report that sees one fails the seal","owner":"seal","source":"seal","status":"waiting"},{"id":"restore-verify","item":"control/restore-verify.json entry","disposition":"not carried: the next drill writes a fresh verdict, and until then the old one describes the source copy","owner":"none","status":"unchecked"},{"id":"warm-cache","item":"local DB, meta dirs, files and env a box holds for the host","disposition":"parked: renamed under .offline-moves on the source after open and on a target that already has them","owner":"landing","status":"waiting"},{"id":"receipts","item":"move-receipts/\u003chost\u003e.json in the source bucket","disposition":"not carried: cleared at finish, and self-checking","owner":"none","status":"unchecked"},{"id":"class","item":"kind, addr, fleet, pool, hibernate, replica, replicas, steering, unit, seed and open run","disposition":"refused: the start door and the start transition refuse every class the move cannot carry","owner":"door","status":"ok"}]}
diff --git a/internal/moves/offline.go b/internal/moves/offline.go
index 58b66cd18..428594c67 100644
--- a/internal/moves/offline.go
+++ b/internal/moves/offline.go
@@ -883,8 +883,9 @@ func OfflineReportFresh(r OfflineReport, now time.Time) bool {
// - A stager report that names StagedRequest names it equal to
// StageRequest; on a self-serve move a stager report naming either
// names both.
-// - A request-bound report is not more than OfflineRequestSkew in the
-// future.
+// - On a self-serve move, a request-bound report is not more than
+// OfflineRequestSkew in the future (an operator move's reader keeps
+// it, as it always has: the operator's answers stay byte for byte).
// - With Participants: the report's BoxID and InstanceID are the pinned
// participant's for its role (ParticipantFor; InstanceID compared only
// when the pinned one is non-empty), and its Box is that participant's
@@ -913,7 +914,7 @@ func ValidateOfflineReportFor(r OfflineReport, m *provision.MoveState, now time.
return errors.New("offline report: a stager report names one of stage_request and staged_request")
}
}
- if RequestBound(r) && r.At.Sub(now) > OfflineRequestSkew {
+ if m.SelfServe != nil && RequestBound(r) && r.At.Sub(now) > OfflineRequestSkew {
return fmt.Errorf("offline report: written %s in the future", r.At.Sub(now).Round(time.Second))
}
if m.Participants != nil {