carlos / platform Public

registry: fleets fixes — rev-counter fingerprint, atomic add, validation

Paul Campbell ee3c3976591a0c1ef7e2432e16148dc07910620b parent a2f5f61
3 files changed, +235 −44
  • internal/registry/fleets.go +97 −24
  • internal/registry/fleets_test.go +121 −14
  • internal/registry/registry.go +17 −6
diff --git a/internal/registry/fleets.go b/internal/registry/fleets.go
index e4a865baa..56d201f08 100644
--- a/internal/registry/fleets.go
+++ b/internal/registry/fleets.go
@@ -10,6 +10,7 @@ package registry
import (
"database/sql"
"fmt"
+ "regexp"
"time"
)
@@ -33,16 +34,40 @@ type FleetBox struct {
AddedAt int64
}
+// tokenHashRe: exactly 64 lowercase hex characters — a sha256 digest,
+// structurally enforcing the "token hashes only, never plaintext" rule at
+// every writer that accepts one (AddFleetBox, SetFleetBoxToken). A bearer
+// token or an empty string both fail this shape.
+var tokenHashRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
+
+func validTokenHash(hash string) bool {
+ return tokenHashRe.MatchString(hash)
+}
+
+// validFleetBoxStatus reports whether status is one of the two values the
+// FleetBox.Status doc comment promises. Rejecting anything else at the
+// writer matters because AddFleetBox's uniqueness guard keys off status =
+// 'active': a typo'd status would make a live box invisible to it.
+func validFleetBoxStatus(status string) bool {
+ return status == "active" || status == "detached"
+}
+
// migrateFleets creates the fleets and fleet_boxes tables — additive
// (CREATE TABLE IF NOT EXISTS), called from migrate() beside the routes DDL,
// so deploying this code over an existing registry is always safe.
+//
+// Both tables carry `rev`, bumped by every writer — see Fingerprint's doc
+// comment (registry.go) for why a plain length/count/rowid aggregate is
+// blind to a fixed-length rewrite (a sha256 token_hash never changes
+// length) and rev is what fixes that.
func (r *Registry) migrateFleets() error {
if _, err := r.sql.Exec(`
CREATE TABLE IF NOT EXISTS fleets (
name TEXT PRIMARY KEY,
account TEXT NOT NULL,
bucket TEXT NOT NULL,
- created_at INTEGER NOT NULL
+ created_at INTEGER NOT NULL,
+ rev INTEGER NOT NULL DEFAULT 0
);`); err != nil {
return err
}
@@ -53,6 +78,7 @@ CREATE TABLE IF NOT EXISTS fleet_boxes (
token_hash TEXT NOT NULL,
status TEXT NOT NULL,
added_at INTEGER NOT NULL,
+ rev INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (fleet, label)
);`)
return err
@@ -86,10 +112,10 @@ func (r *Registry) UpsertFleet(f Fleet) error {
f.CreatedAt = time.Now().Unix()
}
_, err := r.sql.Exec(`
-INSERT INTO fleets (name, account, bucket, created_at)
-VALUES (?, ?, ?, ?)
+INSERT INTO fleets (name, account, bucket, created_at, rev)
+VALUES (?, ?, ?, ?, 1)
ON CONFLICT(name) DO UPDATE SET
- account = excluded.account, bucket = excluded.bucket`,
+ account = excluded.account, bucket = excluded.bucket, rev = fleets.rev + 1`,
f.Name, f.Account, f.Bucket, f.CreatedAt)
return err
}
@@ -131,11 +157,29 @@ func (r *Registry) ListFleets() ([]Fleet, error) {
return out, rows.Err()
}
-// AddFleetBox registers a box to a fleet. It errors if (fleet, label)
-// already names an ACTIVE box — the channel identity must stay unique while
-// live. A label that exists but is DETACHED is reactivated in place with the
-// incoming token/status/added_at, which is the path a re-provisioned box
-// takes back onto its old name.
+// AddFleetBox registers a box to a fleet. It errors if the fleet does not
+// exist, if TokenHash or Status fail their shape checks, and if
+// (fleet, label) already names an ACTIVE box.
+//
+// The fleet-existence check is a plain SELECT rather than a SQLite FOREIGN
+// KEY, because FK enforcement is a per-connection PRAGMA
+// (`foreign_keys=ON`) that Open() never sets and that a fresh short-lived
+// connection (freshFleetsConn, another process entirely) could disagree
+// with — an FK that only some connections enforce is worse than none, since
+// it would look enforced in tests that happen to run through the same
+// handle. Both statements run inside one transaction so the check and the
+// insert see the same snapshot.
+//
+// The active-box check is NOT a read-then-write in Go: it is the INSERT's
+// own ON CONFLICT ... WHERE clause, so a second process racing this call on
+// the same (fleet, label) can never observe a stale "not active yet" read
+// and steal a live channel's token out from under it — SQLite evaluates the
+// WHERE clause and applies (or skips) the DO UPDATE atomically. A label that
+// exists but is DETACHED clears the WHERE guard and is reactivated in place
+// with the incoming token/status/added_at (rev bumped) — the path a
+// re-provisioned box takes back onto its old name. RowsAffected()==0 means
+// the guard fired (an active row was left untouched), which is the signal
+// this returns as the duplicate-active error.
func (r *Registry) AddFleetBox(b FleetBox) error {
if !ValidTunnelName(b.Fleet) {
return fmt.Errorf("invalid fleet name %q", b.Fleet)
@@ -146,6 +190,12 @@ func (r *Registry) AddFleetBox(b FleetBox) error {
if b.Status == "" {
b.Status = "active"
}
+ if !validFleetBoxStatus(b.Status) {
+ return fmt.Errorf("invalid box status %q", b.Status)
+ }
+ if !validTokenHash(b.TokenHash) {
+ return fmt.Errorf("invalid token hash %q (want 64 lowercase hex characters)", b.TokenHash)
+ }
if b.AddedAt == 0 {
b.AddedAt = time.Now().Unix()
}
@@ -153,31 +203,45 @@ func (r *Registry) AddFleetBox(b FleetBox) error {
if err != nil {
return err
}
- var status string
- err = tx.QueryRow(`SELECT status FROM fleet_boxes WHERE fleet = ? AND label = ?`, b.Fleet, b.Label).Scan(&status)
- if err != nil && err != sql.ErrNoRows {
+ var exists int
+ err = tx.QueryRow(`SELECT 1 FROM fleets WHERE name = ?`, b.Fleet).Scan(&exists)
+ if err == sql.ErrNoRows {
tx.Rollback()
- return err
+ return fmt.Errorf("no fleet %q", b.Fleet)
}
- if err == nil && status == "active" {
+ if err != nil {
tx.Rollback()
- return fmt.Errorf("fleet %q already has an active box %q", b.Fleet, b.Label)
+ return err
}
- if _, err := tx.Exec(`
-INSERT INTO fleet_boxes (fleet, label, token_hash, status, added_at)
-VALUES (?, ?, ?, ?, ?)
+ res, err := tx.Exec(`
+INSERT INTO fleet_boxes (fleet, label, token_hash, status, added_at, rev)
+VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(fleet, label) DO UPDATE SET
- token_hash = excluded.token_hash, status = excluded.status, added_at = excluded.added_at`,
- b.Fleet, b.Label, b.TokenHash, b.Status, b.AddedAt); err != nil {
+ token_hash = excluded.token_hash, status = excluded.status, added_at = excluded.added_at,
+ rev = fleet_boxes.rev + 1
+WHERE fleet_boxes.status <> 'active'`,
+ b.Fleet, b.Label, b.TokenHash, b.Status, b.AddedAt)
+ if err != nil {
+ tx.Rollback()
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
tx.Rollback()
return err
}
+ if n == 0 {
+ tx.Rollback()
+ return fmt.Errorf("fleet %q already has an active box %q", b.Fleet, b.Label)
+ }
return tx.Commit()
}
// SetFleetBoxToken rotates a box's stored token hash. Errors when no
// (fleet, label) row exists — a caller minting a fresh token against a box
-// that no longer exists is a race worth reporting, not swallowing.
+// that no longer exists is a race worth reporting, not swallowing. Bumps
+// rev: a sha256 token_hash is always 64 characters, so the rotation alone
+// changes no length Fingerprint could otherwise key off.
func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error {
if !ValidTunnelName(fleet) {
return fmt.Errorf("invalid fleet name %q", fleet)
@@ -185,7 +249,10 @@ func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error {
if !ValidTunnelName(label) {
return fmt.Errorf("invalid box label %q", label)
}
- res, err := r.sql.Exec(`UPDATE fleet_boxes SET token_hash = ? WHERE fleet = ? AND label = ?`, tokenHash, fleet, label)
+ if !validTokenHash(tokenHash) {
+ return fmt.Errorf("invalid token hash %q (want 64 lowercase hex characters)", tokenHash)
+ }
+ res, err := r.sql.Exec(`UPDATE fleet_boxes SET token_hash = ?, rev = rev + 1 WHERE fleet = ? AND label = ?`, tokenHash, fleet, label)
if err != nil {
return err
}
@@ -200,7 +267,10 @@ func (r *Registry) SetFleetBoxToken(fleet, label, tokenHash string) error {
}
// SetFleetBoxStatus flips a box between "active" and "detached". Errors when
-// no (fleet, label) row exists, same contract as SetFleetBoxToken.
+// no (fleet, label) row exists, same contract as SetFleetBoxToken. Rejects
+// any status outside those two values — AddFleetBox's uniqueness guard keys
+// off status = 'active', so a typo'd status here would make a live box
+// invisible to it.
func (r *Registry) SetFleetBoxStatus(fleet, label, status string) error {
if !ValidTunnelName(fleet) {
return fmt.Errorf("invalid fleet name %q", fleet)
@@ -208,7 +278,10 @@ func (r *Registry) SetFleetBoxStatus(fleet, label, status string) error {
if !ValidTunnelName(label) {
return fmt.Errorf("invalid box label %q", label)
}
- res, err := r.sql.Exec(`UPDATE fleet_boxes SET status = ? WHERE fleet = ? AND label = ?`, status, fleet, label)
+ if !validFleetBoxStatus(status) {
+ return fmt.Errorf("invalid box status %q", status)
+ }
+ res, err := r.sql.Exec(`UPDATE fleet_boxes SET status = ?, rev = rev + 1 WHERE fleet = ? AND label = ?`, status, fleet, label)
if err != nil {
return err
}
diff --git a/internal/registry/fleets_test.go b/internal/registry/fleets_test.go
index 777369756..9009b2cab 100644
--- a/internal/registry/fleets_test.go
+++ b/internal/registry/fleets_test.go
@@ -1,6 +1,17 @@
package registry
-import "testing"
+import (
+ "strings"
+ "testing"
+)
+
+// Distinct, realistic-shape (64 lowercase hex) token hashes for tests —
+// AddFleetBox/SetFleetBoxToken now reject anything else.
+var (
+ tokenHashA = strings.Repeat("a", 64)
+ tokenHashB = strings.Repeat("b", 64)
+ tokenHashC = strings.Repeat("c", 64)
+)
// TestFleetRoundTrips pins UpsertFleet/GetFleet/ListFleets against the exact
// shape later tasks depend on (task-1-brief.md's Fleet struct).
@@ -44,7 +55,7 @@ func TestFleetBoxRoundTrips(t *testing.T) {
if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
t.Fatal(err)
}
- want := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "deadbeef", Status: "active", AddedAt: 1700000001}
+ want := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active", AddedAt: 1700000001}
if err := reg.AddFleetBox(want); err != nil {
t.Fatal(err)
}
@@ -57,12 +68,25 @@ func TestFleetBoxRoundTrips(t *testing.T) {
}
}
+// TestAddFleetBoxRejectsUnknownFleet: no FK, so this is the only rail
+// against an orphan box — one authenticating a channel for a fleet with no
+// account/bucket record at all.
+func TestAddFleetBoxRejectsUnknownFleet(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.AddFleetBox(FleetBox{Fleet: "ghost", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err == nil {
+ t.Fatal("AddFleetBox against a never-upserted fleet: got nil error, want one")
+ }
+}
+
// TestAddFleetBoxRejectsDuplicateActive is the brief's core invariant: two
// boxes cannot hold the same (fleet, label) channel identity while both are
// active.
func TestAddFleetBoxRejectsDuplicateActive(t *testing.T) {
reg := openTestRegistry(t)
- box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash1", Status: "active"}
+ if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
+ t.Fatal(err)
+ }
+ box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}
if err := reg.AddFleetBox(box); err != nil {
t.Fatal(err)
}
@@ -76,14 +100,17 @@ func TestAddFleetBoxRejectsDuplicateActive(t *testing.T) {
// a re-provisioned box takes.
func TestAddFleetBoxReactivatesADetachedLabel(t *testing.T) {
reg := openTestRegistry(t)
- box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash1", Status: "active"}
+ if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
+ t.Fatal(err)
+ }
+ box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}
if err := reg.AddFleetBox(box); err != nil {
t.Fatal(err)
}
if err := reg.SetFleetBoxStatus("acme", "pi-1", "detached"); err != nil {
t.Fatal(err)
}
- reAdd := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash2", Status: "active"}
+ reAdd := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashB, Status: "active"}
if err := reg.AddFleetBox(reAdd); err != nil {
t.Fatalf("AddFleetBox over a detached label: %v", err)
}
@@ -91,7 +118,7 @@ func TestAddFleetBoxReactivatesADetachedLabel(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if len(boxes) != 1 || boxes[0].TokenHash != "hash2" || boxes[0].Status != "active" {
+ if len(boxes) != 1 || boxes[0].TokenHash != tokenHashB || boxes[0].Status != "active" {
t.Fatalf("re-add over a detached label = %+v, want reactivated with the new token", boxes)
}
}
@@ -100,10 +127,13 @@ func TestAddFleetBoxReactivatesADetachedLabel(t *testing.T) {
// rely on: channel name "<fleet>/<label>" -> TokenHash, ACTIVE boxes only.
func TestFleetChannelAuthOmitsDetachedBoxes(t *testing.T) {
reg := openTestRegistry(t)
- if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash1", Status: "active"}); err != nil {
+ if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
t.Fatal(err)
}
- if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-2", TokenHash: "hash2", Status: "active"}); err != nil {
+ if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-2", TokenHash: tokenHashB, Status: "active"}); err != nil {
t.Fatal(err)
}
if err := reg.SetFleetBoxStatus("acme", "pi-2", "detached"); err != nil {
@@ -116,14 +146,14 @@ func TestFleetChannelAuthOmitsDetachedBoxes(t *testing.T) {
if _, ok := auth["acme/pi-2"]; ok {
t.Fatalf("FleetChannelAuth included a detached box: %+v", auth)
}
- if got, ok := auth["acme/pi-1"]; !ok || got != "hash1" {
+ if got, ok := auth["acme/pi-1"]; !ok || got != tokenHashA {
t.Fatalf("FleetChannelAuth missing/wrong entry for the active box: %+v", auth)
}
}
func TestSetFleetBoxTokenMissingRowErrors(t *testing.T) {
reg := openTestRegistry(t)
- if err := reg.SetFleetBoxToken("acme", "nope", "hash"); err == nil {
+ if err := reg.SetFleetBoxToken("acme", "nope", tokenHashA); err == nil {
t.Fatal("SetFleetBoxToken on a missing row: got nil error, want one")
}
}
@@ -142,13 +172,13 @@ func TestFleetWritersRejectInvalidNames(t *testing.T) {
if err := reg.UpsertFleet(Fleet{Name: "UPPER"}); err == nil {
t.Fatal("UpsertFleet({Name:UPPER}): got nil error, want one")
}
- if err := reg.AddFleetBox(FleetBox{Fleet: "UPPER", Label: "pi-1"}); err == nil {
+ if err := reg.AddFleetBox(FleetBox{Fleet: "UPPER", Label: "pi-1", TokenHash: tokenHashA}); err == nil {
t.Fatal("AddFleetBox with an invalid fleet name: got nil error, want one")
}
- if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "UPPER"}); err == nil {
+ if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "UPPER", TokenHash: tokenHashA}); err == nil {
t.Fatal("AddFleetBox with an invalid label: got nil error, want one")
}
- if err := reg.SetFleetBoxToken("UPPER", "pi-1", "hash"); err == nil {
+ if err := reg.SetFleetBoxToken("UPPER", "pi-1", tokenHashA); err == nil {
t.Fatal("SetFleetBoxToken with an invalid fleet name: got nil error, want one")
}
if err := reg.SetFleetBoxStatus("acme", "UPPER", "detached"); err == nil {
@@ -156,6 +186,47 @@ func TestFleetWritersRejectInvalidNames(t *testing.T) {
}
}
+// TestFleetBoxWritersRejectInvalidStatus: AddFleetBox and SetFleetBoxStatus
+// both reject a status outside {"active","detached"} — a typo'd status
+// would otherwise make a live box invisible to AddFleetBox's uniqueness
+// guard, which keys off status = 'active'.
+func TestFleetBoxWritersRejectInvalidStatus(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "enabled"}); err == nil {
+ t.Fatal("AddFleetBox with status \"enabled\": got nil error, want one")
+ }
+ if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetFleetBoxStatus("acme", "pi-1", "enabled"); err == nil {
+ t.Fatal("SetFleetBoxStatus(\"enabled\"): got nil error, want one")
+ }
+}
+
+// TestFleetBoxWritersRejectInvalidTokenHash: AddFleetBox and
+// SetFleetBoxToken both require exactly 64 lowercase hex characters —
+// structurally enforcing "hashes only, never plaintext".
+func TestFleetBoxWritersRejectInvalidTokenHash(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range []string{"", "not-a-hash", strings.Repeat("a", 63), strings.Repeat("A", 64)} {
+ if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: bad, Status: "active"}); err == nil {
+ t.Fatalf("AddFleetBox with token hash %q: got nil error, want one", bad)
+ }
+ }
+ if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetFleetBoxToken("acme", "pi-1", "plaintext-token"); err == nil {
+ t.Fatal("SetFleetBoxToken with a plaintext-shaped value: got nil error, want one")
+ }
+}
+
// TestFingerprintChangesOnFleetMutation: Fingerprint must fold the fleets
// table into its aggregate — mutating a FLEET row (not a route) must still
// change it, which is what lets the edge notice a fleet change later.
@@ -185,7 +256,10 @@ func TestFingerprintChangesOnFleetMutation(t *testing.T) {
// exactly how a detach reaches the edge in later tasks).
func TestFingerprintChangesOnFleetBoxMutation(t *testing.T) {
reg := openTestRegistry(t)
- if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: "hash1", Status: "active"}); err != nil {
+ if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
t.Fatal(err)
}
before, err := reg.Fingerprint()
@@ -203,3 +277,36 @@ func TestFingerprintChangesOnFleetBoxMutation(t *testing.T) {
t.Fatalf("fingerprint unchanged across a fleet_boxes status flip: %q", after)
}
}
+
+// TestFingerprintChangesOnTokenRotation is the Critical-1 regression pin: a
+// sha256 hex token_hash is ALWAYS 64 characters, so a rotation between two
+// same-length, different-content hashes changes no LENGTH, no COUNT, no
+// MAX(rowid) — only the rev column moves. Without rev folded into
+// Fingerprint, this rotation would be invisible to the edge's poll and a
+// revoked token would keep authenticating.
+func TestFingerprintChangesOnTokenRotation(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil {
+ t.Fatal(err)
+ }
+ if len(tokenHashA) != len(tokenHashC) {
+ t.Fatalf("test fixture bug: rotation hashes must be equal length, got %d and %d", len(tokenHashA), len(tokenHashC))
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetFleetBoxToken("acme", "pi-1", tokenHashC); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatalf("fingerprint unchanged across a same-length token rotation: %q", after)
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 07951f3c9..6e8f3a4f0 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -1002,12 +1002,23 @@ WHERE kind = 'instance' AND next_due > 0 AND next_due <= ? ORDER BY host`, now)
// that would actually hide a change forever.
//
// fleets and fleet_boxes each get their own weighted scalar subquery (own
-// weight sets — 19/23/29 and 31/37/41/43/47 — so a routes change and a
+// weight sets — 19/23/29/53 and 31/37/41/43/47/59 — so a routes change and a
// fleets change can never cancel each other out either), concatenated onto
// the routes aggregate: still ONE query, so a fleet or fleet_boxes mutation
-// (an account move, a box's status flip, a token rotation) changes the
-// fingerprint exactly like a route write does, which is what lets the edge's
-// Watch poll (edge.go:830) notice a fleet change and reload.
+// changes the fingerprint exactly like a route write does, which is what
+// lets the edge's Watch poll (edge.go:830) notice a fleet change and reload.
+//
+// Both new tables carry a `rev` column, bumped by every writer
+// (UpsertFleet, AddFleetBox, SetFleetBoxToken, SetFleetBoxStatus), folded in
+// here as SUM(rev*53)/SUM(rev*59). This is NOT the same length-based trick
+// the routes aggregate uses above — a sha256 hex token_hash is ALWAYS 64
+// characters, so SetFleetBoxToken's rotation changes no length, no COUNT,
+// no MAX(rowid): without rev the aggregate would be byte-identical across a
+// token rotation, the edge's poll would never fire, and a revoked token
+// would keep authenticating until an unrelated mutation happened to land.
+// rev is what makes a fixed-length-value rewrite visible, the same job
+// COUNT/MAX(rowid) do for inserts/deletes and LENGTH does for
+// variable-length rewrites.
func (r *Registry) Fingerprint() (string, error) {
db, err := sql.Open("sqlite", r.path)
if err != nil {
@@ -1027,12 +1038,12 @@ SELECT
FROM routes)
|| ':' ||
(SELECT COUNT(*) || ':' || COALESCE(MAX(rowid),0) || ':' ||
- COALESCE(SUM(rowid*19 + LENGTH(account)*23 + LENGTH(bucket)*29),0)
+ COALESCE(SUM(rowid*19 + LENGTH(account)*23 + LENGTH(bucket)*29 + rev*53),0)
FROM fleets)
|| ':' ||
(SELECT COUNT(*) || ':' || COALESCE(MAX(rowid),0) || ':' ||
COALESCE(SUM(rowid*31 + LENGTH(fleet)*37 + LENGTH(label)*41
- + LENGTH(token_hash)*43 + LENGTH(status)*47),0)
+ + LENGTH(token_hash)*43 + LENGTH(status)*47 + rev*59),0)
FROM fleet_boxes)
`).Scan(&fp)
return fp, err