carlos / platform Public

settings-5: Astra r2 fix (attribute the box's stored update decision)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5179064512fed5fd5b3271f0063f071063119eb2 parent 40e7782
5 files changed, +286 −13
  • internal/host/updatesreport.go +26 −3
  • internal/host/updatesreport_test.go +165 −7
  • internal/release/adoptsettings_test.go +70 −0
  • internal/release/buildstate.go +19 −2
  • internal/release/updatetarget.go +6 −1
diff --git a/internal/host/updatesreport.go b/internal/host/updatesreport.go
index 6a11276e0..0a98941e2 100644
--- a/internal/host/updatesreport.go
+++ b/internal/host/updatesreport.go
@@ -53,6 +53,15 @@ func updatesReport(reg *registry.Registry, optDir string) map[string]status.Host
for _, rt := range routes {
u, perr := release.ParseUpdateSettingsLenient(rt.UpdateSettings)
c, _ := release.ReadConsumed(optDir, rt.Host)
+ // The consumed file is keyed by hostname only. One that does not name
+ // this route's tenant (a previous occupant's, or one from before root
+ // named the tenant) is no decision for this route: the entry carries
+ // the route's own column and nothing that came from the file or the
+ // build state's watermark (Astra r2 N1, ruling R22).
+ foreign := c != nil && !c.DecidedFor(rt)
+ if foreign {
+ c = nil
+ }
if perr == nil && u.Default() && c == nil {
continue
}
@@ -65,7 +74,15 @@ func updatesReport(reg *registry.Registry, optDir string) map[string]status.Host
// report without a rollout report (Astra r1, R18).
h := status.HostUpdates{Account: release.RouteAccount(rt), App: rt.App}
if perr == nil {
- h.Major = u.Major
+ h.Major = u.Major // the route's own column: its tenant's
+ }
+ if foreign {
+ // Completed and Pending read the watermark, which root takes
+ // from the file and the build state together (Watermark); with
+ // the file not this tenant's, neither is reported until root
+ // decides for this route.
+ out[rt.Host] = h
+ continue
}
if c != nil {
h.Desired, h.Reason, h.Detail, h.Baking = c.Desired, c.Reason, c.Detail, c.Baking
@@ -96,7 +113,9 @@ func updatesReport(reg *registry.Registry, optDir string) map[string]status.Host
// updatesReader is the control socket's reader (plan 3's RegistryReader,
// which keeps the channel fallback, plus root's decision and the serving
-// build).
+// build). Root's decision is added only when it names the route's tenant,
+// as in updatesReport (ruling R22): an instance never reads a previous
+// occupant's decision for its hostname.
func updatesReader(reg *registry.Registry, optDir string) func(string) (instanceupdates.Report, error) {
base := instanceupdates.RegistryReader(reg)
return func(host string) (instanceupdates.Report, error) {
@@ -104,7 +123,11 @@ func updatesReader(reg *registry.Registry, optDir string) func(string) (instance
if err != nil {
return rep, err
}
- if c, _ := release.ReadConsumed(optDir, host); c != nil {
+ rt, err := reg.Get(host)
+ if err != nil || rt == nil {
+ return rep, nil // the base report stands; no decision is added
+ }
+ if c, _ := release.ReadConsumed(optDir, host); c.DecidedFor(*rt) {
rep.Desired, rep.Reason = c.Desired, c.Reason
}
return rep, nil
diff --git a/internal/host/updatesreport_test.go b/internal/host/updatesreport_test.go
index 049ad6f8e..9144dec5a 100644
--- a/internal/host/updatesreport_test.go
+++ b/internal/host/updatesreport_test.go
@@ -11,7 +11,7 @@ import (
func TestTheReportCarriesRootsDecision(t *testing.T) {
b, _, reg := testBuildStates(t)
- if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Desired: "v1", Reason: "major", Detail: "v1", Completed: 2, Baking: "v4"}); err != nil {
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Desired: "v1", Reason: "major", Detail: "v1", Completed: 2, Baking: "v4"}); err != nil {
t.Fatal(err)
}
two := 2
@@ -37,8 +37,10 @@ func TestTheReportNamesTheTenant(t *testing.T) {
if err := reg.Upsert(registry.Route{Host: "b.example.com", Kind: "instance", Account: "bab", App: "shop", Hibernate: true, Socket: "/run/carlos/b.sock"}); err != nil {
t.Fatal(err)
}
+ // Root names the tenant it decided for (R22); the report checks it.
+ acct := map[string][2]string{"a.example.com": {release.OpsAccount, "km"}, "b.example.com": {"bab", "shop"}}
for _, h := range []string{"a.example.com", "b.example.com"} {
- if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: h, Desired: "v1", Reason: "manual"}); err != nil {
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: h, Account: acct[h][0], App: acct[h][1], Desired: "v1", Reason: "manual"}); err != nil {
t.Fatal(err)
}
}
@@ -86,7 +88,7 @@ func TestTheProtocolFollowsTheBuildStateService(t *testing.T) {
func TestTheStatusProbesCarryTheProtocolAndTheDecisions(t *testing.T) {
b, _, reg := testBuildStates(t)
- if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Desired: "v1", Reason: "manual"}); err != nil {
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Desired: "v1", Reason: "manual"}); err != nil {
t.Fatal(err)
}
var p status.Probes
@@ -100,7 +102,7 @@ func TestTheStatusProbesCarryTheProtocolAndTheDecisions(t *testing.T) {
// report must still be made, with the hold it carries (R-SETTINGS-UNREADABLE).
func TestTheReportSurvivesAnUnknownControlName(t *testing.T) {
b, _, reg := testBuildStates(t)
- if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Desired: "v1", Reason: "manual"}); err != nil {
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Desired: "v1", Reason: "manual"}); err != nil {
t.Fatal(err)
}
col := `{"updates":"manual","channel":"stable","control":["updates","from-the-future"]}`
@@ -146,7 +148,7 @@ func TestAnUnknownReasonReportsNoRawDetail(t *testing.T) {
b, _, reg := testBuildStates(t)
raw := `update settings: invalid character 'x' looking for beginning of value`
for _, c := range []struct{ detail, want string }{{raw, ""}, {"v3", "v3"}} {
- if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Reason: "unknown", Detail: c.detail}); err != nil {
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Reason: "unknown", Detail: c.detail}); err != nil {
t.Fatal(err)
}
got := updatesReport(reg, b.optDir)["a.example.com"]
@@ -154,7 +156,7 @@ func TestAnUnknownReasonReportsNoRawDetail(t *testing.T) {
t.Fatalf("detail %q: report = %+v, want detail %q", c.detail, got, c.want)
}
}
- if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Reason: "major", Detail: "not a version!"}); err != nil {
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Reason: "major", Detail: "not a version!"}); err != nil {
t.Fatal(err)
}
if got := updatesReport(reg, b.optDir)["a.example.com"]; got.Detail != "not a version!" {
@@ -167,7 +169,7 @@ func TestAnUnknownReasonReportsNoRawDetail(t *testing.T) {
// alone, and a request is pending only above that.
func TestAnUnreadBuildStateReportsTheConsumedFileAlone(t *testing.T) {
b, _, reg := testBuildStates(t)
- if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Reason: "request", Completed: 2}); err != nil {
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Account: release.OpsAccount, App: "km", Reason: "request", Completed: 2}); err != nil {
t.Fatal(err)
}
set := func(req uint64) {
@@ -201,3 +203,159 @@ func TestAnUnreadBuildStateReportsTheConsumedFileAlone(t *testing.T) {
t.Fatalf("read: report = %+v; want completed 9, failed, nothing pending", got)
}
}
+
+// Astra r2 N1 (ruling R22): the consumed file is keyed by hostname only. A
+// hostname that moves from tenant A to tenant B on one box keeps A's file
+// until root decides for B; the report labels its entry with B's route, so
+// it must carry none of A's decision. B's own column (its major) stays.
+func TestAMovedHostnameReportsNoPreviousTenantsDecision(t *testing.T) {
+ b, _, reg := testBuildStates(t)
+ const host = "a.example.com"
+ three, five := 3, 5
+ // Tenant A's route, and root's real write for it.
+ a := registry.Route{Host: host, Kind: "instance", Account: "cdf", App: "shop", Hibernate: true, Socket: "/run/carlos/a.sock"}
+ if err := reg.Upsert(a); err != nil {
+ t.Fatal(err)
+ }
+ ua := release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &five, InstallRequest: 6}
+ if err := reg.SetUpdateSettings(host, ua.Column()); err != nil {
+ t.Fatal(err)
+ }
+ a.UpdateSettings = ua.Column()
+ in := release.TargetInput{Route: a, Settings: ua, Pointer: &release.ChannelPointer{Channel: "stable", Version: "v-a-head"}, HeadHeld: "baking",
+ Build: release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{Schema: 1, Host: host}}}
+ if err := release.WriteConsumed(b.optDir, release.NextConsumed(in, release.TargetDecision{Version: "v-a-pin", Reason: release.ReasonPin, Detail: "v-a-pin", Fail: 4})); err != nil {
+ t.Fatal(err)
+ }
+ if got := updatesReport(reg, b.optDir)[host]; got.Desired != "v-a-pin" || got.Baking != "v-a-head" || got.Completed != 4 {
+ t.Fatalf("A's own report = %+v; want A's decision (the fixture is wrong otherwise)", got)
+ }
+ // The route is retired (claims off: the row is deleted) and B's arrives on
+ // the same hostname before root has decided for it.
+ if err := reg.Delete(host); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog", Hibernate: true, Socket: "/run/carlos/a.sock"}); err != nil {
+ t.Fatal(err)
+ }
+ ub := release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable", Major: &three, InstallRequest: 2}
+ if err := reg.SetUpdateSettings(host, ub.Column()); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetBuildState(host, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{Schema: 1, Host: host, Completed: 1}}.Column()); err != nil {
+ t.Fatal(err)
+ }
+ got, ok := updatesReport(reg, b.optDir)[host]
+ if !ok {
+ t.Fatal("B's route has settings of its own and no entry")
+ }
+ if got.Account != "dee" || got.App != "blog" {
+ t.Fatalf("B's entry names %s/%s, want dee/blog", got.Account, got.App)
+ }
+ if got.Desired != "" || got.Reason != "" || got.Detail != "" || got.Baking != "" ||
+ got.Completed != 0 || got.CompletedFailed || got.Pending != 0 {
+ t.Fatalf("B's entry carries a decision root did not make for B: %+v", got)
+ }
+ if got.Major == nil || *got.Major != 3 {
+ t.Fatalf("B's entry major = %v, want B's own 3", got.Major)
+ }
+ rep, err := updatesReader(reg, b.optDir)(host)
+ if err != nil || rep.Desired != "" || rep.Reason != "" {
+ t.Fatalf("B's socket report = %+v, %v; want none of A's decision", rep, err)
+ }
+ if rep.Settings.Updates != release.UpdatesManual || rep.Settings.Major == nil || *rep.Settings.Major != 3 {
+ t.Fatalf("B's socket report settings = %+v, want B's own", rep.Settings)
+ }
+
+ // Root's next pass decides for B, over A's file: now the decision is B's.
+ b2 := registry.Route{Host: host, Kind: "instance", Account: "dee", App: "blog", UpdateSettings: ub.Column()}
+ prev, err := release.ReadConsumed(b.optDir, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ inB := release.TargetInput{Route: b2, Settings: ub, Consumed: prev,
+ Build: release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{Schema: 1, Host: host, Completed: 1}}}
+ if err := release.WriteConsumed(b.optDir, release.NextConsumed(inB, release.TargetDecision{Version: "v-b", Reason: release.ReasonManual, Detail: "v-b"})); err != nil {
+ t.Fatal(err)
+ }
+ got = updatesReport(reg, b.optDir)[host]
+ if got.Account != "dee" || got.App != "blog" || got.Desired != "v-b" || got.Reason != "manual" || got.Detail != "v-b" {
+ t.Fatalf("B's entry after root decided for B = %+v; want B's decision", got)
+ }
+ if rep, err := updatesReader(reg, b.optDir)(host); err != nil || rep.Desired != "v-b" || rep.Reason != "manual" {
+ t.Fatalf("B's socket report after root decided for B = %+v, %v", rep, err)
+ }
+}
+
+// R22: a consumed file from before root named the tenant is decided for no
+// one: the entry names the route's tenant and carries the route's own major,
+// and nothing that came from the file or the build state.
+func TestAnUnattributedConsumedFileReportsNoDecision(t *testing.T) {
+ b, _, reg := testBuildStates(t)
+ const host = "a.example.com"
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: host, Desired: "v1", Reason: "pin", Detail: "v1",
+ Baking: "v2", Completed: 3, CompletedFailed: true}); err != nil {
+ t.Fatal(err)
+ }
+ two := 2
+ if err := reg.SetUpdateSettings(host, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &two, InstallRequest: 7}.Column()); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetBuildState(host, release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{Schema: 1, Host: host, Completed: 5}}.Column()); err != nil {
+ t.Fatal(err)
+ }
+ got := updatesReport(reg, b.optDir)[host]
+ if got.Account != release.OpsAccount || got.App != "km" || got.Major == nil || *got.Major != 2 {
+ t.Fatalf("entry = %+v; want tenant %s/km and the route's own major", got, release.OpsAccount)
+ }
+ if got.Desired != "" || got.Reason != "" || got.Detail != "" || got.Baking != "" ||
+ got.Completed != 0 || got.CompletedFailed || got.Pending != 0 {
+ t.Fatalf("an unattributed file's decision was reported: %+v", got)
+ }
+ if rep, err := updatesReader(reg, b.optDir)(host); err != nil || rep.Desired != "" || rep.Reason != "" {
+ t.Fatalf("socket report = %+v, %v; want no decision from an unattributed file", rep, err)
+ }
+ // A route with default settings and only an unattributed file has
+ // nothing of its own to report, as before root decides for it.
+ if err := reg.SetUpdateSettings(host, ""); err != nil {
+ t.Fatal(err)
+ }
+ if got := updatesReport(reg, b.optDir); got != nil {
+ t.Fatalf("report = %+v; want none for a default route with no decision of its own", got)
+ }
+}
+
+// R22: a file that names the route's tenant is reported in full; one that
+// names the same account and another app, or another account and the same
+// app, is not.
+func TestAConsumedFileIsReportedOnlyForTheTenantItNames(t *testing.T) {
+ b, _, reg := testBuildStates(t)
+ const host = "a.example.com"
+ if err := reg.SetUpdateSettings(host, release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", InstallRequest: 7}.Column()); err != nil {
+ t.Fatal(err)
+ }
+ write := func(account, app string) {
+ t.Helper()
+ if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: host, Account: account, App: app,
+ Desired: "v1", Reason: "pin", Detail: "v1", Baking: "v2", Completed: 3, CompletedFailed: true}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write(release.OpsAccount, "km")
+ got := updatesReport(reg, b.optDir)[host]
+ if got.Desired != "v1" || got.Reason != "pin" || got.Detail != "v1" || got.Baking != "v2" ||
+ got.Completed != 3 || !got.CompletedFailed || got.Pending != 7 {
+ t.Fatalf("matching file: entry = %+v; want the whole decision", got)
+ }
+ if rep, err := updatesReader(reg, b.optDir)(host); err != nil || rep.Desired != "v1" || rep.Reason != "pin" {
+ t.Fatalf("matching file: socket report = %+v, %v", rep, err)
+ }
+ for _, other := range [][2]string{{release.OpsAccount, "shop"}, {"cdf", "km"}, {"", "km"}, {release.OpsAccount, ""}} {
+ write(other[0], other[1])
+ got := updatesReport(reg, b.optDir)[host]
+ if got.Desired != "" || got.Reason != "" || got.Detail != "" || got.Baking != "" ||
+ got.Completed != 0 || got.CompletedFailed || got.Pending != 0 {
+ t.Fatalf("file for %v: entry = %+v; want no decision", other, got)
+ }
+ }
+}
diff --git a/internal/release/adoptsettings_test.go b/internal/release/adoptsettings_test.go
index 64f468b5b..be53e847e 100644
--- a/internal/release/adoptsettings_test.go
+++ b/internal/release/adoptsettings_test.go
@@ -653,6 +653,9 @@ func TestNextConsumed(t *testing.T) {
tc.in.Route = registry.Route{Host: "a.example.com", UpdateSettings: tc.in.Settings.Column()}
// Every decision names the column it was made for (R-HELD-PURE).
tc.want.Settings = SettingsDigest(tc.in.Settings.Column())
+ // And the route's tenant (R22): a route with no account of its
+ // own is the platform's.
+ tc.want.Account = OpsAccount
var before *Consumed
if tc.in.Consumed != nil {
cp := *tc.in.Consumed
@@ -940,3 +943,70 @@ func TestNextConsumedRecordsTheSettingsItDecidedFor(t *testing.T) {
t.Fatal("two columns share a digest")
}
}
+
+// Astra r2 N1 (ruling R22): root's consumed file names the tenant it decided
+// for, at both of the pass's write sites (the decision, and the unreadable
+// column's reason unknown), and a hostname that moves to another tenant is
+// rewritten naming the new one, never keeping the old file's tenant.
+func TestRootsConsumedFileNamesTheRoutesTenant(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, reg := t.TempDir(), testRegistry(t)
+ const host = "a.example.com"
+ for _, ta := range [][2]string{{"cdf", "km"}, {"dee", "blog"}} {
+ shipRelease(t, s, ta[0], ta[1], "v1", adoptPlatform, []byte("v1"))
+ if err := Promote(ctx, s, priv, ta[0], ta[1], "canary/x", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ route := func(account, app string) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: account, App: app, Channel: "canary/x", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetUpdateSettings(host, UpdateSettings{Updates: UpdatesManual, Channel: "canary/x"}.Column()); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetBuildState(host, BuildStateCopy{Known: true, Replica: true}.Column()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ check := func(when, account, app string) {
+ t.Helper()
+ c, err := ReadConsumed(opt, host)
+ if err != nil || c == nil {
+ t.Fatalf("%s: consumed = %+v, %v", when, c, err)
+ }
+ if c.Account != account || c.App != app || c.Reason != string(ReasonUnknown) {
+ t.Fatalf("%s: consumed names %q/%q (reason %q), want %s/%s", when, c.Account, c.App, c.Reason, account, app)
+ }
+ }
+ route("cdf", "km")
+ passWithSettings(t, reg, s, pub, opt)
+ check("tenant A's decision", "cdf", "km")
+ // Claims off: the row is retired by deletion, and B's arrives on the same
+ // hostname. Root's next pass reads A's file and writes over it.
+ if err := reg.Delete(host); err != nil {
+ t.Fatal(err)
+ }
+ route("dee", "blog")
+ passWithSettings(t, reg, s, pub, opt)
+ check("tenant B's decision over A's file", "dee", "blog")
+ // The other write site: a column that does not parse is recorded as
+ // reason unknown, for the route's tenant too (A's again, over B's file).
+ if err := reg.Delete(host); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "cdf", App: "km", Channel: "canary/x", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetUpdateSettings(host, "{not json"); err != nil {
+ t.Fatal(err)
+ }
+ out := passWithSettings(t, reg, s, pub, opt)
+ if !strings.Contains(out, "update settings unreadable") {
+ t.Fatalf("the pass did not take the unreadable-column path:\n%s", out)
+ }
+ check("an unreadable column", "cdf", "km")
+}
diff --git a/internal/release/buildstate.go b/internal/release/buildstate.go
index 08680432d..8c32008ed 100644
--- a/internal/release/buildstate.go
+++ b/internal/release/buildstate.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/controlname"
+ "github.com/carlosframework/platform/internal/registry"
)
// ResolvedRequest is an install request R resolved to a build T (settings
@@ -102,8 +103,24 @@ type Consumed struct {
// only for the column it was decided for: after a member's change, a
// missing or stale decision never makes a wake unheld. Empty in a file
// from before this field: no decision for any column.
- Settings string `json:"settings,omitempty"`
- At time.Time `json:"at"`
+ Settings string `json:"settings,omitempty"`
+ // Account and App name the tenant this decision was made for: the
+ // route's (RouteAccount, Route.App) when root wrote it (Astra r2 N1,
+ // ruling R22). The file is keyed by hostname only, and a hostname can
+ // move between tenants on one box, so a report shows the decision only
+ // to the route it names (DecidedFor). Empty in a file from before these
+ // fields: decided for no tenant.
+ Account string `json:"account,omitempty"`
+ App string `json:"app,omitempty"`
+ At time.Time `json:"at"`
+}
+
+// DecidedFor is whether c is root's decision for rt's tenant: c names an
+// account and an app, and they are rt's (RouteAccount, rt.App). A file with
+// no tenant (from before ruling R22) or another tenant's is decided for no
+// one here, and a report shows none of it.
+func (c *Consumed) DecidedFor(rt registry.Route) bool {
+ return c != nil && c.Account != "" && c.App != "" && c.Account == RouteAccount(rt) && c.App == rt.App
}
// SettingsDigest names one update_settings column exactly (the registry's
diff --git a/internal/release/updatetarget.go b/internal/release/updatetarget.go
index 7c7cb4ce1..9d14aa6c0 100644
--- a/internal/release/updatetarget.go
+++ b/internal/release/updatetarget.go
@@ -232,7 +232,8 @@ func tagInMajor(tag string, major int) bool {
// (in.Build.Read()), as Target does no request bookkeeping without it.
// - Desired, Reason and Detail are the decision; Settings names the
// update_settings column it was made for (in.Route.UpdateSettings,
-// ruling R-HELD-PURE); At is the writer's.
+// ruling R-HELD-PURE); Account and App name the route's tenant (ruling
+// R22); At is the writer's.
func NextConsumed(in TargetInput, dec TargetDecision) Consumed {
next := Consumed{Host: in.Route.Host}
if in.Consumed != nil {
@@ -267,5 +268,9 @@ func NextConsumed(in TargetInput, dec TargetDecision) Consumed {
// The column this decision was made for (ruling R-HELD-PURE): the held
// wake check reads Reason only while the column is still this one.
next.Settings = SettingsDigest(in.Route.UpdateSettings)
+ // The tenant it was made for (Astra r2 N1, ruling R22), always the
+ // route's: a file read from a previous occupant of this hostname never
+ // passes its tenant on.
+ next.Account, next.App = RouteAccount(in.Route), in.Route.App
return next
}