carlos / platform Public

console: the move pass never closes an abort; pages a full hour apart (Task 7 fix round 1)

Ruling 1: an aborting record is closed only by its source, after it has
retired its own snapshots, receipts, ledger and journal (movesabort.go);
nothing on the record shows that it did, so the console's abort-complete
path is gone. An abort with nothing owed that stays open past its lane's
horizon (the claim's quiet_after for the target region) plus
moveCloseGrace is paged once (MV-N333, MV-N334) and left to the source.
TestRecoveryPassNeverClosesAnAbort.

Ruling 3: the last attempted page per finding is kept in a new console
table, move_pages (classified NonApp in the store gate). The release
deadline's incident repeats only a full hour after the last attempt; the
other pages are once per move. TestConsolePagesThroughAlerting now pages
either side of a whole hour since the deadline and proves the spacing,
and the durability of the last attempt's time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev cb6a242d4014c4cc5a9a4c59acdf10ee6984e4fd parent 4952efc
6 files changed, +187 −57
  • approved_moves_copy_test.go +3 −3
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +2 −0
  • internal/console/movesrecovery.go +82 −36
  • internal/console/movesrecovery_test.go +68 −18
  • internal/console/store.go +31 −0
  • internal/console/store_gate.go +1 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 6616cbad8..f0e8f1820 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -75,7 +75,7 @@ var movesCopyIDs = []string{
"MV-N307", "MV-N308", "MV-N309", "MV-N310", "MV-N311", "MV-N312",
"MV-N313", "MV-N314",
"MV-N315", "MV-N316", "MV-N317", "MV-N318",
- "MV-N325", "MV-N326", "MV-N327", "MV-N328", "MV-N329", "MV-N330", "MV-N331", "MV-N332",
+ "MV-N325", "MV-N326", "MV-N327", "MV-N328", "MV-N329", "MV-N330", "MV-N331", "MV-N332", "MV-N333", "MV-N334",
}
// copyRow is one row of the record. The File cell is the file the sentence is
@@ -750,8 +750,8 @@ var movesReturnedSentences = map[string]map[string]bool{
"internal/host/movesreport.go": {"t1Below": true, "t3Differs": true, "t3Shadowed": true, "t4Failed": true,
"t6NoRoom": true, "t8Count": true, "t12NotQuiet": true, "replicaReason": true},
"internal/host/movesrollback.go": {"stuckPage": true},
- // The console's move pass pages (Plan 5 Task 7, MV-N325 to MV-N332).
- "internal/console/movesrecovery.go": {"moveWaitingPage": true, "movePreparingPageText": true, "moveOrphanPage": true, "moveCloseOverduePage": true},
+ // The console's move pass pages (Plan 5 Task 7, MV-N325 to MV-N334).
+ "internal/console/movesrecovery.go": {"moveWaitingPage": true, "movePreparingPageText": true, "moveOrphanPage": true, "moveCloseOverduePage": true, "moveAbortOverduePage": true},
}
// movesLiteralFuncs are functions whose every string literal is a sentence
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 03f5e87d3..4651f5fb7 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -283,6 +283,8 @@ Task 7's pages, the console's move pass (`internal/console/movesrecovery.go`, §
| MV-N330 | internal/console/movesrecovery.go | The host claim of <host> is held by the move <id>, and the instance record carries no move. Nothing releases it on its own. An operator must check the record. | pending |
| MV-N331 | internal/console/movesrecovery.go | carlos move close overdue: <host> | pending |
| MV-N332 | internal/console/movesrecovery.go | The move <id> of <host> was ready to close for more than 10 minutes and no box closed it. The console has closed it. | pending |
+| MV-N333 | internal/console/movesrecovery.go | carlos move abort not closed: <host> | pending |
+| MV-N334 | internal/console/movesrecovery.go | The abort of the move <id> of <host> has nothing left to park in <to>, and <box> has not closed it. <box> closes it once it has removed the move's snapshots from its own bucket. Check <box>. | pending |
## Floor gate fix (#267 review)
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go
index ab9aed45d..db883ea40 100644
--- a/internal/console/movesrecovery.go
+++ b/internal/console/movesrecovery.go
@@ -54,22 +54,28 @@ import (
// conditional record store (stampLostMoveTarget), outside the grant
// (p10 Task G).
// 7. A close whose stamps are in and whose op owes nothing: the record's
-// close (close, rolled-back-close, revert-close or abort-complete). The
-// boxes close on their own in the pass the close becomes due; the
-// console writes it only when it has been due for moveCloseGrace, and
-// pages that it did.
+// close (close, rolled-back-close or revert-close). The boxes close on
+// their own in the pass the close becomes due; the console writes it
+// only when it has been due for moveCloseGrace, and pages that it did.
+// An abort is never closed here (fix round 1, ruling 1): only the
+// source closes it, after it retired its own snapshots, receipts,
+// ledger and journal (movesabort.go), and nothing on the record shows
+// that it did. An abort due past the lane's horizon (the claim's
+// quiet_after for the target region) plus moveCloseGrace is paged
+// once, and left to the source.
// 8. The release: the record closed (LastMove names the op) and the claim
// not yet released. This is the second half of every record-first close
// (ruling M4); after a box crashed between the halves, only this pass
// writes it, because no box visits a record without a move.
//
// Pages (§13) go through alerting.Page on the sinks cmd/console arms, best
-// effort: a rollback waiting past its release deadline (an incident, once an
-// hour), a move preparing for over an hour (a warning, once), an orphan past
-// the seal (an incident, once) and an overdue close the console wrote (a
-// warning, once). The rate limit is durable: each page's rule is recorded in
-// the console's alert_seen table before the next pass, so a restarted console
-// does not repeat it.
+// effort: a rollback waiting past its release deadline (an incident, again
+// only a full hour after the last attempt), a move preparing for over an
+// hour, an orphan past the seal (an incident), an overdue close the console
+// wrote, and an overdue abort the console leaves to its source (each once).
+// The rate limit is durable: the last attempt's time is kept per finding in
+// the console's store (move_pages, fix round 1 ruling 3), written whether or
+// not delivery worked, so a restarted console keeps the spacing.
//
// A relocating claim is region HA's, and the pass does nothing for it.
@@ -83,9 +89,6 @@ const (
// moveCloseGrace is how long a close must have been due before the
// console writes it instead of the boxes.
moveCloseGrace = 10 * time.Minute
- // movePageBox is the alert_seen box the move pass's pages are recorded
- // under.
- movePageBox = "moves"
)
type moveRecoveryAction string
@@ -99,6 +102,9 @@ const (
moveActRecord moveRecoveryAction = "record"
moveActStampLost moveRecoveryAction = "stamp-lost"
moveActClose moveRecoveryAction = "close"
+ // moveActAbortOverdue writes nothing: an abort due to close, which only
+ // its source may close, is checked for being overdue and paged.
+ moveActAbortOverdue moveRecoveryAction = "abort-overdue"
)
type movePageKind string
@@ -108,6 +114,7 @@ const (
movePagePreparing movePageKind = "preparing"
movePageOrphan movePageKind = "orphan"
movePageCloseOverdue movePageKind = "close-overdue"
+ movePageAbortOverdue movePageKind = "abort-overdue"
)
// moveRecoveryInput is what one observation saw. Start is the op's move-start
@@ -203,7 +210,7 @@ func decideMoveRecovery(in moveRecoveryInput) moveRecoveryDecision {
case m.State == provision.MoveReverting && m.ReopenedAt != nil && rec.Region == m.From && op.Kind == hostclaim.KindReverting && op.Phase == hostclaim.PhaseOpened:
d.Action, d.Record = moveActClose, hostops.RecoveryMoveRevertClose
case m.State == provision.MoveAborting && op.Kind == hostclaim.KindMoving && op.Phase == hostclaim.PhaseAborting:
- d.Action, d.Record = moveActClose, hostops.RecoveryMoveAbortComplete
+ d.Action = moveActAbortOverdue
}
return d
}
@@ -293,7 +300,7 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant
}
d := decideMoveRecovery(in)
for _, kind := range d.Pages {
- s.pageMove(ctx, kind, o, rec, now)
+ s.pageMove(ctx, kind, o, c, rec, now)
}
switch d.Action {
case moveActResolveStart:
@@ -338,7 +345,12 @@ func (s *Server) movesRecovery(ctx context.Context, g *hostops.MoveRecoveryGrant
return err
}
log.Printf("console: moves: %s: move %s closed by the console (%s); the release follows", o.Host, o.OpID, d.Record)
- s.pageMove(ctx, movePageCloseOverdue, o, rec, now)
+ s.pageMove(ctx, movePageCloseOverdue, o, c, rec, now)
+ return nil
+ case moveActAbortOverdue:
+ if s.moveAbortOverdue(o, c.QuietAfter[rec.Move.To], now) {
+ s.pageMove(ctx, movePageAbortOverdue, o, c, rec, now)
+ }
return nil
}
return nil
@@ -364,12 +376,11 @@ func (s *Server) sourceFence(ctx context.Context, c *hostclaim.Claim) (*hostclai
return &hostclaim.FenceRef{Enrollment: enrollment, Key: key, SHA256: hex.EncodeToString(sum[:])}, nil
}
-// moveCloseOverdue reports whether this close has been due for
-// moveCloseGrace, remembering when it was first seen due. The memory is the
-// process's: a restarted console waits the grace again, which only delays
-// the console's close.
-func (s *Server) moveCloseOverdue(o hostops.ConsoleMoveObservation, action hostops.MoveRecoveryRecordAction, now time.Time) bool {
- key := o.Host + "|" + o.OpID + "|" + string(action)
+// moveDueSince is when the pass first saw this close due, remembering now
+// if it had not. The memory is the process's: a restarted console waits the
+// grace again, which only delays the console's close or page. Entries a day
+// old are dropped.
+func (s *Server) moveDueSince(key string, now time.Time) time.Time {
s.moveCloseMu.Lock()
defer s.moveCloseMu.Unlock()
if s.moveCloseSeen == nil {
@@ -383,27 +394,49 @@ func (s *Server) moveCloseOverdue(o hostops.ConsoleMoveObservation, action hosto
first, ok := s.moveCloseSeen[key]
if !ok {
s.moveCloseSeen[key] = now
- return false
+ return now
}
- if now.Sub(first) < moveCloseGrace {
+ return first
+}
+
+// moveCloseOverdue reports whether this close has been due for
+// moveCloseGrace; when it has, the memory is cleared for the close.
+func (s *Server) moveCloseOverdue(o hostops.ConsoleMoveObservation, action hostops.MoveRecoveryRecordAction, now time.Time) bool {
+ key := o.Host + "|" + o.OpID + "|" + string(action)
+ if now.Sub(s.moveDueSince(key, now)) < moveCloseGrace {
return false
}
+ s.moveCloseMu.Lock()
delete(s.moveCloseSeen, key)
+ s.moveCloseMu.Unlock()
return true
}
-// pageMove sends one page if its rule has not been paged: once per move,
-// or, for the release deadline, once per hour since the deadline. The rule
-// is recorded whether or not delivery worked (pages are best effort).
-func (s *Server) pageMove(ctx context.Context, kind movePageKind, o hostops.ConsoleMoveObservation, rec provision.Record, now time.Time) {
+// moveAbortOverdue reports whether an abort with nothing owed has stayed
+// unclosed past its lane's horizon (quiet, the claim's quiet_after for the
+// target region, when the claim has one) plus moveCloseGrace, and has been
+// seen due for moveCloseGrace itself. The page it leads to is once per move
+// (pageMove), so the memory is kept.
+func (s *Server) moveAbortOverdue(o hostops.ConsoleMoveObservation, quiet, now time.Time) bool {
+ since := s.moveDueSince(o.Host+"|"+o.OpID+"|abort", now)
+ if now.Sub(since) < moveCloseGrace {
+ return false
+ }
+ return quiet.IsZero() || !now.Before(quiet.Add(moveCloseGrace))
+}
+
+// pageMove sends one page for a finding (its kind, host and move) unless
+// the finding was paged before: ever, for a once-per-move page, or within
+// the last full hour, for the release deadline. The attempt's time is
+// recorded whether or not delivery worked (pages are best effort).
+func (s *Server) pageMove(ctx context.Context, kind movePageKind, o hostops.ConsoleMoveObservation, c *hostclaim.Claim, rec provision.Record, now time.Time) {
rule := string(kind) + " " + o.Host + " " + o.OpID
level := alerting.LevelWarning
+ every := time.Duration(0) // once per move
var subject, body string
switch kind {
case movePageReleaseDeadline:
- rb := rec.Move.Rollback
- rule += fmt.Sprintf(" %d", int(now.Sub(*rb.ReleaseDeadline)/time.Hour))
- level = alerting.LevelIncident
+ level, every = alerting.LevelIncident, time.Hour
subject, body = moveWaitingPage(o.Host, o.OpID, rec.Move.To)
case movePagePreparing:
subject, body = movePreparingPageText(o.Host, o.OpID, rec.Move.To)
@@ -412,26 +445,29 @@ func (s *Server) pageMove(ctx context.Context, kind movePageKind, o hostops.Cons
subject, body = moveOrphanPage(o.Host, o.OpID)
case movePageCloseOverdue:
subject, body = moveCloseOverduePage(o.Host, o.OpID)
+ case movePageAbortOverdue:
+ subject, body = moveAbortOverduePage(o.Host, o.OpID, rec.Move.To, c.Op.Intent["source"])
default:
return
}
if s.store == nil {
return
}
- seen, known, err := s.store.AlertSeen(movePageBox, rule, noMailboxAddress)
+ last, paged, err := s.store.MovePageAttempt(rule)
if err != nil {
log.Printf("console: moves: %s: the page record for %s: %v", o.Host, rule, err)
return
}
- if known && seen {
+ if paged && (every == 0 || now.Sub(last) < every) {
+ return
+ }
+ if err := s.store.SetMovePageAttempt(rule, now); err != nil {
+ log.Printf("console: moves: %s: record the page %s: %v", o.Host, rule, err)
return
}
if err := alerting.Page(ctx, level, subject, body); err != nil {
log.Printf("console: moves: %s: page %s: %v", o.Host, kind, err)
}
- if err := s.store.SetAlertSeen(movePageBox, rule, noMailboxAddress, true); err != nil {
- log.Printf("console: moves: %s: record the page %s: %v", o.Host, rule, err)
- }
}
// moveWaitingPage is a rollback waiting past its release deadline (§7.8
@@ -468,3 +504,13 @@ func moveCloseOverduePage(host, id string) (subject, body string) {
body = fmt.Sprintf("The move %s of %s was ready to close for more than 10 minutes and no box closed it. The console has closed it.", id, host)
return subject, body
}
+
+// moveAbortOverduePage is an abort that owes nothing and that its source has
+// not closed (fix round 1, ruling 1): the subject (MV-N333) and the body
+// (MV-N334).
+func moveAbortOverduePage(host, id, to, box string) (subject, body string) {
+ subject = fmt.Sprintf("carlos move abort not closed: %s", host)
+ body = fmt.Sprintf("The abort of the move %s of %s has nothing left to park in %s, and %s has not closed it. %s closes it once it has removed the move's snapshots from its own bucket. Check %s.",
+ id, host, to, box, box, box)
+ return subject, body
+}
diff --git a/internal/console/movesrecovery_test.go b/internal/console/movesrecovery_test.go
index 82d7ff161..0e43f0ce7 100644
--- a/internal/console/movesrecovery_test.go
+++ b/internal/console/movesrecovery_test.go
@@ -150,12 +150,17 @@ func TestRecoveryPassOneActionPerObservation(t *testing.T) {
}{
{mvClaim(hostclaim.KindRestoring, hostclaim.PhaseOpened), mvRecord(provision.MoveRollingBack, rb, func(r *provision.Record) { r.Move.Rollback.TargetParkedAt = mvAt(0) }), hostops.RecoveryMoveRolledBackClose},
{mvClaim(hostclaim.KindReverting, hostclaim.PhaseOpened), mvRecord(provision.MoveReverting, func(r *provision.Record) { r.Move.ReopenedAt = mvAt(0) }), hostops.RecoveryMoveRevertClose},
- {mvClaim(hostclaim.KindMoving, hostclaim.PhaseAborting), mvRecord(provision.MoveAborting), hostops.RecoveryMoveAbortComplete},
} {
if d := decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: c.claim, Rec: c.rec}); d.Action != moveActClose || d.Record != c.want {
t.Fatalf("%s/%s: %+v, want close %s", c.claim.Op.Kind, c.claim.Op.Phase, d, c.want)
}
}
+ // An abort the console never closes (fix round 1, ruling 1): only the
+ // source closes it, after it retired its own state. Due, it is at most
+ // a page.
+ if d := decideMoveRecovery(moveRecoveryInput{Now: mvT0, Claim: mvClaim(hostclaim.KindMoving, hostclaim.PhaseAborting), Rec: mvRecord(provision.MoveAborting)}); d.Action != moveActAbortOverdue || d.Record != "" {
+ t.Fatalf("a due abort: %+v, want only the overdue check", d)
+ }
// The record closed, the claim not released: the release.
closed := func(r *provision.Record) {
r.Move, r.LastMove = nil, &provision.MoveOutcome{ID: "mv", Outcome: "done", At: mvT0}
@@ -628,16 +633,57 @@ func TestRecoveryPassAbortsAfterSixHours(t *testing.T) {
}
}
-// TestConsolePagesThroughAlerting (§13, M14): the pass pages through
-// alerting.Page on the armed sinks. A rollback waiting past its release
-// deadline is an incident, paged once an hour and no more often, and the
-// rate limit is durable: it is kept in the console's store (alert_seen), so
-// a restarted console does not page again inside the hour since the
-// deadline.
+// TestRecoveryPassNeverClosesAnAbort (fix round 1, ruling 1): the source's
+// retirement of its own state is blocked, so it never closes the aborting
+// record, though nothing is owed under the op. The console does not close
+// it either: once the abort has been due past the lane's horizon (the
+// claim's quiet_after for the target region) plus moveCloseGrace, it pages
+// once, and the record stays aborting.
+func TestRecoveryPassNeverClosesAnAbort(t *testing.T) {
+ w := startedMovePass(t, "code-mvpass-abort-blocked")
+ horizon := w.clock.Add(20 * time.Minute)
+ w.setClaim(t, func(c *hostclaim.Claim) {
+ c.Op.Phase, c.Obligations = hostclaim.PhaseAborting, nil
+ if c.QuietAfter == nil {
+ c.QuietAfter = map[string]time.Time{}
+ }
+ c.QuietAfter["uk"] = horizon
+ })
+ j17SetRecord(t, w.fx, func(r *provision.Record) { r.Move.State = provision.MoveAborting })
+ claim := claimBytes(t, w.fx, "acme.go.tito.io")
+ w.pass(t)
+ w.advance(moveCloseGrace + time.Minute)
+ w.pass(t)
+ if len(w.paged(t)) != 0 {
+ t.Fatalf("paged before the lane's horizon plus the grace: %+v", w.paged(t))
+ }
+ w.advance(20 * time.Minute) // past quiet_after + the grace
+ w.pass(t)
+ w.advance(2 * time.Hour)
+ w.pass(t)
+ w.pass(t)
+ if m := w.record(t).Move; m == nil || m.State != provision.MoveAborting {
+ t.Fatalf("the console closed the abort: %+v %+v", m, w.record(t).LastMove)
+ }
+ if claimBytes(t, w.fx, "acme.go.tito.io") != claim {
+ t.Fatal("the console wrote the aborting claim")
+ }
+ p := w.paged(t)
+ if len(p) != 1 || p[0].Level != alerting.LevelWarning || !strings.Contains(p[0].Body, w.id) || !strings.Contains(p[0].Body, "ie-box") {
+ t.Fatalf("the overdue abort's pages: %+v", p)
+ }
+}
+
+// TestConsolePagesThroughAlerting (§13, M14; fix round 1, ruling 3): the
+// pass pages through alerting.Page on the armed sinks. A rollback waiting
+// past its release deadline is an incident, paged again only a full hour
+// after the last attempt: two passes either side of a whole hour since the
+// deadline do not both page. The last attempt's time is durable, in the
+// console's store, so a restarted console keeps the spacing.
func TestConsolePagesThroughAlerting(t *testing.T) {
w := landingMovePass(t, "code-mvpass-pages")
- rb, at := newRollbackID(t), time.Now().UTC().Add(-time.Hour)
- deadline := at.Add(15 * time.Minute)
+ rb, at := newRollbackID(t), w.clock.Add(-74*time.Minute)
+ deadline := at.Add(15 * time.Minute) // 59 minutes ago
j17SetRecord(t, w.fx, func(r *provision.Record) {
r.Region = "ie"
r.Move.State = provision.MoveRollingBack
@@ -645,27 +691,31 @@ func TestConsolePagesThroughAlerting(t *testing.T) {
SourcePhase: provision.RbSourceWaiting, ReleaseDeadline: &deadline}
})
w.setClaim(t, func(c *hostclaim.Claim) { c.Op.Kind, c.Op.Phase = hostclaim.KindRestoring, hostclaim.PhaseClaimed })
- w.pass(t)
+ first := w.clock
w.pass(t)
p := w.paged(t)
if len(p) != 1 || p[0].Kind != "page" || p[0].Level != alerting.LevelIncident || !strings.Contains(p[0].Subject, "uk") || !strings.Contains(p[0].Body, w.id) {
t.Fatalf("the release deadline's page: %+v", p)
}
+ // Across the whole hour since the deadline (59 to 61 minutes): no page.
+ w.advance(2 * time.Minute)
+ w.pass(t)
+ if len(w.paged(t)) != 1 {
+ t.Fatalf("paged again two minutes later, across the hour since the deadline: %+v", w.paged(t))
+ }
// A restarted console: the in-memory state is gone, the store is not.
- // The deadline passed 45 minutes ago; ten minutes on is the same hour
- // since the deadline.
w.fx.srv.moveCloseSeen = nil
- w.advance(10 * time.Minute)
+ if last, ok, err := w.fx.st.MovePageAttempt("release-deadline acme.go.tito.io " + w.id); err != nil || !ok || !last.Equal(first.Truncate(time.Second)) {
+ t.Fatalf("the last attempt's durable time: %v %v %v, want %v", last, ok, err, first)
+ }
+ w.advance(56 * time.Minute) // 58 minutes after the first page
w.pass(t)
if len(w.paged(t)) != 1 {
t.Fatalf("paged again inside the hour: %+v", w.paged(t))
}
- if seen, known, err := w.fx.st.AlertSeen(movePageBox, "release-deadline acme.go.tito.io "+w.id+" 0", noMailboxAddress); err != nil || !known || !seen {
- t.Fatalf("the page's durable record: seen %v known %v err %v", seen, known, err)
- }
- w.advance(time.Hour)
+ w.advance(3 * time.Minute) // 61 minutes after the first page
w.pass(t)
if len(w.paged(t)) != 2 {
- t.Fatalf("not paged again the next hour: %+v", w.paged(t))
+ t.Fatalf("not paged again a full hour after the last attempt: %+v", w.paged(t))
}
}
diff --git a/internal/console/store.go b/internal/console/store.go
index f005f1ddc..51d0619d6 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -350,6 +350,14 @@ func (s *Store) migrate() error {
`CREATE TABLE IF NOT EXISTS edge_proof_seen (
id INTEGER PRIMARY KEY CHECK (id = 1),
seen_at INTEGER NOT NULL
+);`,
+ // The console's move pass pages (movesrecovery.go): the last
+ // attempted page per finding, so a repeating page keeps a full
+ // hour between attempts across restarts, and a once-only page is
+ // never repeated.
+ `CREATE TABLE IF NOT EXISTS move_pages (
+ rule TEXT PRIMARY KEY,
+ attempted_at INTEGER NOT NULL
);`,
} {
if _, err := s.db.Exec(stmt); err != nil {
@@ -2182,6 +2190,29 @@ ON CONFLICT(box, rule, address) DO UPDATE SET firing = excluded.firing`, box, ru
return err
}
+// ---- move pass pages (movesrecovery.go) -------------------------------------
+
+// MovePageAttempt is when the move pass last attempted the page for rule,
+// to the second, and whether it ever did.
+func (s *Store) MovePageAttempt(rule string) (time.Time, bool, error) {
+ var at int64
+ err := s.db.QueryRow(`SELECT attempted_at FROM move_pages WHERE rule = ?`, rule).Scan(&at)
+ if err == sql.ErrNoRows {
+ return time.Time{}, false, nil
+ }
+ if err != nil {
+ return time.Time{}, false, err
+ }
+ return time.Unix(at, 0).UTC(), true, nil
+}
+
+// SetMovePageAttempt records an attempt at the page for rule at.
+func (s *Store) SetMovePageAttempt(rule string, at time.Time) error {
+ _, err := s.db.Exec(`INSERT INTO move_pages (rule, attempted_at) VALUES (?, ?)
+ON CONFLICT(rule) DO UPDATE SET attempted_at = excluded.attempted_at`, rule, at.Unix())
+ return err
+}
+
// ---- grants (the console's Keymail access tokens) ---------------------------
// SetGrantToken stores the Keymail access token the user's OAuth approval
diff --git a/internal/console/store_gate.go b/internal/console/store_gate.go
index aac845c62..c2d72d223 100644
--- a/internal/console/store_gate.go
+++ b/internal/console/store_gate.go
@@ -209,6 +209,7 @@ var claimSQLTables = map[string]claimSQLTable{
"memberships": {NonApp: "account membership, keyed by account and address; names no app"},
"invites": {NonApp: "account invites, keyed by account and address; names no app"},
"alert_seen": {NonApp: "operator alert memory, keyed by box, rule and address"},
+ "move_pages": {NonApp: "the move pass's last page attempt per finding (rule names the host and the move); no app column"},
"edge_proof_seen": {NonApp: "single-row edge proof marker"},
"cli_auth_requests": {NonApp: "device-code logins in flight, keyed by code hash"},
"step_ups": {NonApp: "passkey step-up marks, keyed by session hash"},