// Package-file doc: control/serial is the deploy signal (carlos-deploy spec // §3): an UNSIGNED counter the console bumps after any bucket write a box // loop consumes. It carries no authority — it can only trigger verified work // sooner — which is why it is the one object in the bucket outside the // Ed25519 envelope. Boxes react to any CHANGE, not only increments, so a // reset or a lost-increment race can never wedge a box into ignoring bumps. package release import ( "context" "encoding/json" "errors" "fmt" "os" "time" ) // SerialKey deliberately sits at the bucket top level: nothing that lists or // parses app shapes (ChannelsPrefix, appPrefix) ever sees it. const SerialKey = "control/serial" type SerialDoc struct { Schema int `json:"schema"` Serial int64 `json:"serial"` BumpedAt string `json:"bumped_at"` } // ReadSerial returns the current serial, its etag for CAS, and nil for a // missing object (zero doc, "" etag) — absence is the ordinary pre-first-bump // state, not an error. A present-but-unparseable object also reads as the // zero doc WITH its real etag, so the next bump overwrites the garbage. func ReadSerial(ctx context.Context, s Store) (SerialDoc, string, error) { body, etag, err := s.Get(ctx, SerialKey) if errors.Is(err, os.ErrNotExist) { return SerialDoc{}, "", nil } if err != nil { return SerialDoc{}, "", err } var doc SerialDoc if json.Unmarshal(body, &doc) != nil { return SerialDoc{}, etag, nil // garbage: recoverable, next bump replaces it } return doc, etag, nil } // BumpSerial increments the serial with a small CAS retry loop. Callers must // treat failure as log-only: a lost bump costs latency (the hourly floor // still converges everything), never correctness, and must never fail the // user-facing write that triggered it. func BumpSerial(ctx context.Context, s Store, now time.Time) error { var lastErr error for attempt := 0; attempt < 4; attempt++ { doc, etag, err := ReadSerial(ctx, s) if err != nil { return err } next := SerialDoc{Schema: 1, Serial: doc.Serial + 1, BumpedAt: now.UTC().Format(time.RFC3339)} body, err := json.Marshal(next) if err != nil { return err } // A caller whose context ended stops here, before the write, every // attempt: a store may ignore the context (DirStore does), and a // request that has ended must announce nothing (db region forward, // Astra round 1, P2). if err := ctx.Err(); err != nil { return err } if _, err = s.Put(ctx, SerialKey, body, etag); err == nil { return nil } else if errors.Is(err, ErrCASConflict) { lastErr = err continue // someone else bumped — their change signal serves ours too, // but retry so OUR write's announcement isn't silently coalesced away } else { return err } } return fmt.Errorf("bump %s: lost CAS race %d times: %w", SerialKey, 4, lastErr) }