carlos / platform Public

settings-3 task 1: release.UpdateSettings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 34d4d76d08c9d1344dd62a7ff398a5a4f7ce17cf parent 70f8119
2 files changed, +136 −0
  • internal/release/updatesettings.go +89 −0
  • internal/release/updatesettings_test.go +47 −0
diff --git a/internal/release/updatesettings.go b/internal/release/updatesettings.go
new file mode 100644
index 000000000..e2faaab6d
--- /dev/null
+++ b/internal/release/updatesettings.go
@@ -0,0 +1,89 @@
+package release
+
+import (
+ "encoding/json"
+ "fmt"
+ "slices"
+)
+
+// The update settings vocabulary (instance update settings spec §3).
+const (
+ UpdatesAuto = "auto"
+ UpdatesManual = "manual"
+
+ ControlUpdates = "updates"
+ ControlChannel = "channel"
+ ControlMajor = "major"
+ ControlPin = "pin"
+)
+
+// UpdateSettings are one instance's EFFECTIVE settings: the record's explicit
+// values over the app's defaults, resolved by reconcile
+// (provision.EffectiveUpdates) and carried to root in the registry's
+// update_settings column. A preference, not a security boundary (spec §2
+// decision 5): root still installs only builds §4.1 authorizes.
+type UpdateSettings struct {
+ Updates string `json:"updates"`
+ Channel string `json:"channel"`
+ Major *int `json:"major,omitempty"`
+ Pin string `json:"pin,omitempty"`
+ InstallRequest uint64 `json:"install_request,omitempty"`
+ InstallFastTrack bool `json:"install_fast_track,omitempty"`
+ Control []string `json:"control,omitempty"`
+}
+
+// ParseUpdateSettings reads the column. "" is every row before this feature,
+// and reads as auto: today's adoption, byte for byte.
+func ParseUpdateSettings(col string) (UpdateSettings, error) {
+ if col == "" {
+ return UpdateSettings{Updates: UpdatesAuto}, nil
+ }
+ var u UpdateSettings
+ if err := json.Unmarshal([]byte(col), &u); err != nil {
+ return UpdateSettings{}, fmt.Errorf("update settings: %v", err)
+ }
+ if u.Updates != UpdatesAuto && u.Updates != UpdatesManual {
+ return UpdateSettings{}, fmt.Errorf("update settings: updates %q", u.Updates)
+ }
+ if u.Major != nil && *u.Major < 0 {
+ return UpdateSettings{}, fmt.Errorf("update settings: major %d", *u.Major)
+ }
+ if u.Pin != "" && (!ValidVersion(u.Pin) || u.Pin == "dev") {
+ return UpdateSettings{}, fmt.Errorf("update settings: pin %q", u.Pin)
+ }
+ if u.Channel != "" && !ValidChannel(u.Channel) {
+ return UpdateSettings{}, fmt.Errorf("update settings: channel %q", u.Channel)
+ }
+ if !ValidControl(u.Control) {
+ return UpdateSettings{}, fmt.Errorf("update settings: control %q", u.Control)
+ }
+ return u, nil
+}
+
+// Column is the canonical JSON reconcile writes. Default settings are "":
+// a row is byte-identical to before this feature until someone sets
+// something.
+func (u UpdateSettings) Column() string {
+ if u.Default() && len(u.Control) == 0 {
+ return ""
+ }
+ b, _ := json.Marshal(u)
+ return string(b)
+}
+
+// Default reports today's behaviour: auto, no major hold, no pin, no request.
+func (u UpdateSettings) Default() bool {
+ return (u.Updates == "" || u.Updates == UpdatesAuto) && u.Major == nil && u.Pin == "" && u.InstallRequest == 0
+}
+
+// ValidControl accepts a set of setting names an instance may change itself.
+func ValidControl(names []string) bool {
+ seen := map[string]bool{}
+ for _, n := range names {
+ if seen[n] || !slices.Contains([]string{ControlUpdates, ControlChannel, ControlMajor, ControlPin}, n) {
+ return false
+ }
+ seen[n] = true
+ }
+ return true
+}
diff --git a/internal/release/updatesettings_test.go b/internal/release/updatesettings_test.go
new file mode 100644
index 000000000..c6c6f9001
--- /dev/null
+++ b/internal/release/updatesettings_test.go
@@ -0,0 +1,47 @@
+package release
+
+import "testing"
+
+func TestEmptyColumnIsToday(t *testing.T) {
+ u, err := ParseUpdateSettings("")
+ if err != nil || u.Updates != UpdatesAuto || u.Major != nil || u.Pin != "" || u.InstallRequest != 0 {
+ t.Fatalf("ParseUpdateSettings(\"\") = %+v, %v; want auto and nothing else", u, err)
+ }
+ if !u.Default() {
+ t.Fatal("the empty column is not the default")
+ }
+}
+
+func TestColumnRoundTrips(t *testing.T) {
+ two := 2
+ u := UpdateSettings{Updates: UpdatesManual, Channel: "stable", Major: &two, Pin: "v9",
+ InstallRequest: 4, InstallFastTrack: true, Control: []string{ControlUpdates, ControlPin}}
+ got, err := ParseUpdateSettings(u.Column())
+ if err != nil || got.Column() != u.Column() || *got.Major != 2 {
+ t.Fatalf("round trip = %+v, %v", got, err)
+ }
+ if u.Default() {
+ t.Fatal("manual is not the default")
+ }
+}
+
+func TestDefaultIgnoresChannelAndControl(t *testing.T) {
+ u := UpdateSettings{Updates: UpdatesAuto, Channel: "beta", Control: []string{ControlUpdates}}
+ if !u.Default() {
+ t.Fatal("auto with no major, pin or request is today's behaviour whatever the channel")
+ }
+}
+
+func TestParseRefusesNonsense(t *testing.T) {
+ for _, col := range []string{`{`, `{"updates":"sometimes"}`, `{"updates":"auto","major":-1}`, `{"updates":"auto","pin":"../x"}`} {
+ if _, err := ParseUpdateSettings(col); err == nil {
+ t.Errorf("%s was accepted", col)
+ }
+ }
+}
+
+func TestValidControl(t *testing.T) {
+ if !ValidControl(nil) || !ValidControl([]string{"updates", "pin"}) || ValidControl([]string{"updates", "updates"}) || ValidControl([]string{"bake"}) {
+ t.Fatal("ValidControl is wrong")
+ }
+}