carlos / platform Public

settings-3 final review: an unchanged off-stable major never blocks a full-state clear

The instance PUT refused any major sent off stable, so a client sending
its full state ({"major":2,"pin":""}) on a beta record that kept a
stale major 2 could not clear its pin. Only a major the request newly
sets or changes is refused off stable now; an unchanged one is masked
during validation and kept (R4). The census digest for
handleAPIUpdatesPut is refreshed: the edit adds no record write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 4b7da831bf2bb1e58f9855b4aff1807957ef46b0 parent 9002e8f
3 files changed, +43 −6
  • internal/console/apiupdates.go +10 −5
  • internal/console/apiupdates_test.go +32 −0
  • internal/provision/plan9_b8_census_test.go +1 −1

CI passed — run details

diff --git a/internal/console/apiupdates.go b/internal/console/apiupdates.go
index 5a53c9e6e..1a9fc9389 100644
--- a/internal/console/apiupdates.go
+++ b/internal/console/apiupdates.go
@@ -157,7 +157,10 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) {
return
}
next := *rec
- majorSet := false // this request sets major to a number
+ // majorSet: this request sets major to a number the record does not
+ // already hold. A major re-sent unchanged (a form that sends its full
+ // state) is not a new major, so it is not refused off stable either.
+ majorSet := false
if v, has := req["updates"]; has {
str, ok := updatesValue(v)
if !ok {
@@ -172,7 +175,7 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, updatewords.Text("settings.console.bad-major"))
return
}
- next.Major, majorSet = major, major != nil
+ next.Major, majorSet = major, major != nil && !sameMajor(rec.Major, major)
}
if v, has := req["pin"]; has {
var str string
@@ -191,9 +194,11 @@ func (s *Server) handleAPIUpdatesPut(w http.ResponseWriter, r *http.Request) {
}
next.Pin = str
}
- // Only a major THIS request sets is refused off stable. A major left by
- // a channel move is ignored by reconcile (and shown as major_ignored); it
- // neither blocks an unrelated change nor is silently cleared by one.
+ // Only a major THIS request sets or changes is refused off stable. A
+ // major left by a channel move is ignored by reconcile (and shown as
+ // major_ignored); it neither blocks an unrelated change, nor a clear sent
+ // beside it unchanged, nor is it silently cleared by one (R4). Validation
+ // below runs with it masked.
if majorSet && next.Channel != "stable" {
jsonError(w, http.StatusBadRequest, fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), rec.Host, next.Channel))
return
diff --git a/internal/console/apiupdates_test.go b/internal/console/apiupdates_test.go
index 9f4b3be03..075aeb6ad 100644
--- a/internal/console/apiupdates_test.go
+++ b/internal/console/apiupdates_test.go
@@ -332,6 +332,38 @@ func TestStaleMajorOffStableIsLeftAlone(t *testing.T) {
}
}
+// Astra r1 #2: a client that sends its full state resends the stale major
+// beside a clear. The unchanged major is not a new major off stable: the
+// clear goes through, the major is kept (R4), and that holds while the fleet
+// gate is closed too. A changed major off stable is still refused.
+func TestFullStateClearBesideAStaleMajor(t *testing.T) {
+ for _, enforced := range []bool{true, false} {
+ t.Run(fmt.Sprintf("enforced=%v", enforced), func(t *testing.T) {
+ fx := updatesFixture(t)
+ fx.boxesEnforce(enforced)
+ two := 2
+ fx.putRecord(provision.Record{Schema: 1, Host: "a.example.com", App: "km", Account: "bab", Channel: "beta",
+ Updates: release.UpdatesManual, Major: &two, Pin: "v1"})
+ st, out := sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2, "pin": ""}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Pin != "" || rec.Major == nil || *rec.Major != 2 {
+ t.Fatalf("pin cleared beside the unchanged stale major: %d %s, record %+v", st, out, rec)
+ }
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 2, "pin": "", "updates": "auto"}, fx.member)
+ if rec := fx.record("a.example.com"); st != http.StatusOK || rec.Updates != release.UpdatesAuto || rec.Major == nil || *rec.Major != 2 {
+ t.Fatalf("manual turned off beside the unchanged stale major: %d %s, record %+v", st, out, rec)
+ }
+ // A changed major off stable is still refused, before the gate.
+ st, out = sendJSON(t, http.MethodPut, fx.ts.URL+updURL, map[string]any{"major": 3, "pin": ""}, fx.member)
+ if st != http.StatusBadRequest || jsonErrorText(t, out) != fmt.Sprintf(updatewords.Text("settings.console.major-off-stable"), "a.example.com", "beta") {
+ t.Fatalf("a changed major off stable: %d %s", st, out)
+ }
+ if rec := fx.record("a.example.com"); rec.Major == nil || *rec.Major != 2 {
+ t.Fatalf("a refused major was written: %+v", rec)
+ }
+ })
+ }
+}
+
func TestMajorOnStableIsSetAndCleared(t *testing.T) {
fx := updatesFixture(t)
fx.boxesEnforce(true)
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index cdb87b4a9..9312d3d6b 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -590,7 +590,7 @@ var plan9B8CensusInventory = []censusEntry{
{"internal/console/apisidecars.go", "Server.handleAPISidecarCreate", "record-writer", "key:CreateSidecarRecord", "a7d5eb534b540499b9355b455966e733d61c904be5cc5a76a168833df59de1c3"},
{"internal/console/apistatusinstances.go", "Server.handleAPIStatusInstances", "key-site", "lit:instances", "2b4b9a2bea5f2fc5727869a5dcb8ae96c020e72571621f19898b195ca3b61aaa"},
{"internal/console/apisystemupdate.go", "Server.mirrorSystemArtifact", "nonrecord", "sel:Put", "76a8dcb558e3723ad5774788da37c22234c229dc3257ded5a6c7b04746c69c2f"},
- {"internal/console/apiupdates.go", "Server.handleAPIUpdatesPut", "record-writer", "key:UpdateRecord", "eb201dd17f0d407af087c974e14d0f713e9a8e3eedb5575d8f27ec5824c2244f"},
+ {"internal/console/apiupdates.go", "Server.handleAPIUpdatesPut", "record-writer", "key:UpdateRecord", "53044a537795302e7ffd8fa72799b7a77e7bed277f00ad17ed1fd465b7f7cfaa"},
{"internal/console/apiupdates.go", "Server.handleAPIUpdatesInstall", "record-writer", "key:UpdateRecord", "880c396f0c9d3365ac216f15e75a44d3e81e24191240f041132854e9b7f6e78a"},
{"internal/console/appsettings.go", "Server.handleAppGate", "record-writer", "key:UpdateRecord", "011431f5459529c0abf354edc2cb772cb6a7f2816d3bb50d91598d7d241838ce"},
// Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers.