carlos / platform Public

edges: explicit alias provenance in the registry and the reconciler; the lifetimes writer publishes held rows as holding entries; the offline view publishes immutable revisioned snapshots

Self-serve moves plan 3 Task 1.

- registry: route_aliases.explicit (RouteAlias.Explicit), migrated
  additively, written by UpsertAlias's conflict clause, scanned by
  ListAliases and folded into Fingerprint when set.
- domains: an exact claim's alias is explicit (a legacy row is upgraded
  on the next pass); instance-derived names and the synthetic catch-all
  row are not; the derived skip compares provenance, so a detached
  explicit name derived to the same target is rewritten not explicit,
  and the keep-as-it-is paths drop a stale explicit mark.
- edgesfile: AliasPublishable, the one alias publication rule.
- host: with lifetimes on, the edges-file writer publishes held rows
  whose hold op is the offline view's move id as offline_moves holding
  entries (explicit publishable aliases only, owner projection, pair,
  self-serve estimate); the offline view publishes immutable revisioned
  snapshots under pubMu with Subscribe; the moves pass's listing failure
  publishes a failed snapshot; the edges-file loop takes the armed view
  and wakes on a change of its holds' digest.
- edge: HoldSnapshot and HoldState DTOs (holding.go).
- census: plan14-root-reads-self-serve-moves-3.tsv (PROPOSED), the
  plan 3 record-write inventory, and the proposal note.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 17658338a3e0d4c7c5f3135841c2dd9866f25c85 parent 5992af0
23 files changed, +1512 −40
  • docs/superpowers/notes/census-proposal-self-serve-moves-3.md +56 −0
  • internal/domains/aliases.go +30 −4
  • internal/domains/aliases_explicit_test.go +114 −0
  • internal/edge/holding.go +28 −0
  • internal/edgesfile/eligible.go +32 −0
  • internal/edgesfile/eligible_test.go +37 −0
  • internal/edgesfile/testdata/offline-moves.json +44 −0
  • internal/host/agent.go +1 −1
  • internal/host/boxaddress_test.go +7 −7
  • internal/host/e2e_bridge.go +7 −1
  • internal/host/edgesclaims_test.go +1 −1
  • internal/host/edgesfile.go +139 −6
  • internal/host/edgesfile_holding_e2e_test.go +64 −0
  • internal/host/edgesfile_holding_test.go +537 −0
  • internal/host/edgesfile_test.go +4 −3
  • internal/host/movesoffline.go +149 −4
  • internal/host/movespass.go +6 −0
  • internal/provision/plan14_root_reads_test.go +15 −0
  • internal/provision/plan9_b8_census_test.go +1 −1
  • internal/provision/self_serve_moves3_census_test.go +40 −0
  • internal/provision/testdata/plan14-root-reads-self-serve-moves-3.tsv +29 −0
  • internal/registry/alias_explicit_test.go +135 −0
  • internal/registry/registry.go +36 −12

CI failed — run details

CI log
=== gate ===
sigwho: pid 2 ppid 1 pgid 0 sid 0; child pid 3
.amadan/test-gate.sh
gate: 2026-10-24T02:50:54Z on aarch64, 32 cpus, 61296 MB available, go1.26.8 linux/arm64
gate: +0s start launchctx
gate: +0s start vet
gate: +0s start gofmt
gate: +1s start rest
gate: +22s start internal_console@0
gate: +22s start internal_console@1
gate: +22s start internal_console@2
gate: +22s start internal_console@3
gate: +22s start internal_console@4
gate: +22s start internal_console@5
gate: +22s start internal_console@6
gate: +22s start internal_console@7
gate: +22s waiting for internal_console's shards before the next split package
gate: +192s start root@0
gate: +192s start root@1
gate: +192s start root@2
gate: +192s waiting for root's shards before the next split package

amadan: step gate failed: signal: terminated

amadan: job failed at step gate — its output is above, not at the tail of this log
diff --git a/docs/superpowers/notes/census-proposal-self-serve-moves-3.md b/docs/superpowers/notes/census-proposal-self-serve-moves-3.md
new file mode 100644
index 000000000..c1c0e74ee
--- /dev/null
+++ b/docs/superpowers/notes/census-proposal-self-serve-moves-3.md
@@ -0,0 +1,56 @@
+# Self-serve moves plan 3: census proposal
+
+Status: proposed, marked PROPOSED-self-serve-moves-3. Not an accepted ruling; the census rulings on this plan are Paul's to make.
+
+The root-read overlay is `internal/provision/testdata/plan14-root-reads-self-serve-moves-3.tsv`. It is loaded in `TestPlan14RootReadCensus` (`internal/provision/plan14_root_reads_test.go`) after every existing overlay block and before `compareRootReads`, with the preceding seal (plan 2's, `f7f1a9f9…`) verified first:
+
+```go
+if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+}
+if err = loadRootReadInventory(root+"/internal/provision/testdata/plan14-root-reads-self-serve-moves-3.tsv", &inv, true); err != nil {
+ t.Fatal(err)
+}
+seal = "272a2c07e3ed3e8df91d45561b002492f3f358527011339659043c2a137a80fd"
+```
+
+The record-write overlay is `plan14SelfServeMoves3WriteCensusInventory` in `internal/provision/self_serve_moves3_census_test.go`, built over plan 2's inventory; `TestPlan9B8RecordWriteCensus` takes it. Later tasks of the plan extend both.
+
+## Task 1: alias provenance and the writer's holding entries
+
+No record envelope changes. No root read, record read, class or task is added.
+
+The tracked field is `route_aliases.explicit` (`RouteAlias.Explicit`, `Explicit` in the overlay's `@fields` row). It says whether an alias row is backed by an exact domains claim on its own name. A held row's holding entry lists only explicit rows.
+
+Seven accepted-legacy units are re-pinned in place. Each carries `rev3=PROPOSED-self-serve-moves-3` and a reviewed basis, and takes a new frozen fingerprint:
+
+- `registry.go:Registry.migrate` adds the column to the create statement, plus one idempotent `ALTER TABLE route_aliases ADD COLUMN explicit INTEGER NOT NULL DEFAULT 0` (the duplicate-column rule) for older tables.
+- `registry.go:Registry.ListAliases` scans the column.
+- `registry.go:Registry.UpsertAlias` writes it on insert and in the conflict clause (`explicit=excluded.explicit`). The DNS and certificate stamps are still preserved.
+- `registry.go:Registry.Fingerprint` folds it into the alias part, only when it is set, so a registry with no explicit row digests as before.
+- `domains/aliases.go:ReconcileAliases` upserts a claim-backed alias as explicit on every pass, so a legacy row is upgraded on the first pass. The synthetic `*.<host>` catch-all row is written not explicit.
+- `domains/aliases.go:deriveInstanceAliases`: its skip also compares provenance (one new `registry:Explicit` site). A derived name that a detached exact claim left explicit, on the same target and owner, is rewritten not explicit. Both keep-as-it-is paths hand the kept row to `keepDerived`.
+- `host/edgesfile.go:edgesFileLoop` takes the armed worker's offline view (`armedOfflineView`, read each tick). With lifetimes on, it folds the holds' digest into the fingerprint the schedule watches, and hands the writer that view's holds (`holdsOf`).
+
+Two new units are frozen as accepted legacy:
+
+- `domains/aliases.go:keepDerived` rewrites a kept row that no listed claim backs to not explicit. Its target, owner and stamps are unchanged.
+- `edgesfile/eligible.go:AliasPublishable` is the writer's one alias publication rule, a pure predicate over one row.
+
+Three units are classed authority:
+
+- `host/edgesfile.go:edgesFileWriteClaims` is re-pinned at F,J1. Its served alias loop now decides through `AliasPublishable`. With lifetimes on and a holding source, it publishes held rows as `offline_moves` holding entries, separate from `hosts`.
+- `host/edgesfile.go:holdingEntries` is new at F,J1. It reads the rows the writer listed, their path prefixes and their explicit alias rows, plus the view's move for each held host.
+- `host/agent.go:runAgent` is re-pinned at B,C,D,F. It also hands the loop `armedOfflineView`.
+
+None of these adds a file, record, release or claim read. The seal moves to `272a2c07…`.
+
+Envelopes re-pinned:
+
+- `internal/domains`
+- `internal/edge`: the new `holding.go` holds the `HoldSnapshot` and `HoldState` DTOs and reads nothing.
+- `internal/edgesfile`: the new `eligible.go`.
+- `internal/host`: `edgesfile.go`, `movesoffline.go` (the view's revisioned snapshots), `movespass.go` (the listing failure publishes a failed snapshot), `agent.go` and `e2e_bridge.go`.
+- `internal/registry`
+
+The record-write census is unchanged by this task. The edges-file writer still makes its one bucket write through `putReplacing` (nonrecord `sel:Put`, unchanged), to `control/edges/<box>.json`, never a record key. `holdingEntries` writes nothing. The registry and reconciler changes are SQLite. `plan14SelfServeMoves3WriteCensusInventory` therefore adds and removes nothing yet.
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go
index 46557e30c..9c8b5ed13 100644
--- a/internal/domains/aliases.go
+++ b/internal/domains/aliases.go
@@ -173,6 +173,7 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi
if a.Account == c.Account && a.App == c.App && strings.HasSuffix(a.Alias, "."+parent) &&
!strings.Contains(strings.TrimSuffix(a.Alias, "."+parent), ".") {
desired[a.Alias] = true
+ keepDerived(reg, a, listed)
}
}
break
@@ -208,6 +209,7 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi
}
derived++
desired[name] = true
+ keepDerived(reg, a, listed)
}
sayInstanceCatchallOnce(c.Host, "a record of label "+label+" could not be judged; its name is kept as it is until it can be")
continue
@@ -218,12 +220,16 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi
}
derived++
desired[name] = true
- if a, ok := have[name]; ok && a.Host == rt.Host && a.Account == c.Account && a.App == c.App {
+ // The row wanted is derived, not explicit (self-serve moves plan
+ // 3 Task 1): a row the name's detached exact claim left explicit
+ // on the same target and owner is rewritten, so it leaves the
+ // holding entries.
+ if a, ok := have[name]; ok && a.Host == rt.Host && a.Account == c.Account && a.App == c.App && !a.Explicit {
continue // already the row we want: no write, so no reload
}
if err := reg.UpsertAlias(registry.RouteAlias{
Alias: name, Host: rt.Host, Account: c.Account, App: c.App,
- CreatedAt: now().Unix(),
+ CreatedAt: now().Unix(), Explicit: false,
}); err != nil {
log.Printf("domains: upsert instance alias %s: %v", name, err)
}
@@ -231,6 +237,21 @@ func deriveInstanceAliases(claims []Claim, listed map[string]bool, routes []regi
}
}
+// keepDerived is the provenance half of keeping a catch-all's existing row
+// as it is for a pass: a row that no listed exact claim backs is not
+// explicit, so one a detached claim left explicit is rewritten derived
+// (target, owner and stamps unchanged). A row whose name is listed is the
+// claim half's to write.
+func keepDerived(reg *registry.Registry, a registry.RouteAlias, listed map[string]bool) {
+ if !a.Explicit || listed[a.Alias] {
+ return
+ }
+ a.Explicit = false
+ if err := reg.UpsertAlias(a); err != nil {
+ log.Printf("domains: upsert instance alias %s: %v", a.Alias, err)
+ }
+}
+
// ReconcileAliases derives route_aliases, wildcard_domains and route_paths
// from the global claim records. All three tables are wholly pass-owned:
// every row traces to a claim, so removal needs no ownership flag. The
@@ -326,10 +347,13 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
// malformed rather than as somebody else's route.
log.Printf("domains: alias %s refused: another box serves that hostname as a route", c.Host)
} else {
+ // An exact claim on this very name backs the row: explicit
+ // (self-serve moves plan 3 Task 1). Written every pass, so a
+ // legacy row from before the column is upgraded on the first.
desired[c.Host] = true
if err := reg.UpsertAlias(registry.RouteAlias{
Alias: c.Host, Host: c.Route, Account: c.Account, App: c.App,
- CreatedAt: now().Unix(),
+ CreatedAt: now().Unix(), Explicit: true,
}); err != nil {
log.Printf("domains: upsert alias %s: %v", c.Host, err)
}
@@ -547,10 +571,12 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist
// which is what star asks about.
log.Printf("domains: catch-all %s refused: another box serves that hostname as a route", star)
} else {
+ // The synthetic catch-all row is derived from a wildcard
+ // claim, not an exact claim on its own name: not explicit.
desired[star] = true
if err := reg.UpsertAlias(registry.RouteAlias{
Alias: star, Host: primaryHost, Account: c.Account, App: c.App,
- CreatedAt: now().Unix(),
+ CreatedAt: now().Unix(), Explicit: false,
}); err != nil {
log.Printf("domains: upsert catch-all %s: %v", star, err)
}
diff --git a/internal/domains/aliases_explicit_test.go b/internal/domains/aliases_explicit_test.go
new file mode 100644
index 000000000..618f70d4f
--- /dev/null
+++ b/internal/domains/aliases_explicit_test.go
@@ -0,0 +1,114 @@
+package domains
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// explicitStore is a parent claim example.com held by account bab, the
+// instance catch-all *.example.com of its app shop, and whatever exact
+// claims the test adds.
+func explicitStore(t *testing.T) release.Store {
+ t.Helper()
+ s := release.NewDirStore(t.TempDir())
+ putClaim(t, s, Claim{Host: "example.com", Account: "bab", App: "shop-site"})
+ putClaim(t, s, Claim{Host: "*.example.com", Account: "bab", App: "shop", Instances: true})
+ return s
+}
+
+// route_aliases.explicit (self-serve moves plan 3 Task 1): a row backed by
+// an exact claim on its own name is explicit; a name an instance catch-all
+// derives is not, and neither is a wildcard claim's synthetic "*.<host>"
+// row; a legacy row (written before the column) that a current exact claim
+// backs is upgraded on the next pass.
+func TestAliasReconcilerWritesProvenance(t *testing.T) {
+ s := explicitStore(t)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bab.example.net", "acme", "bab", "shop")
+ addInstance(t, reg, "beta.bab.example.net", "beta", "bab", "shop")
+ addRoute(t, reg, "shop.example.org", "bab", "shop")
+ putClaim(t, s, Claim{Host: "shop.example.com", Account: "bab", App: "shop", Route: "acme.bab.example.net"})
+ putClaim(t, s, Claim{Host: "shop.example.org", Account: "bab", App: "shop", Wildcard: true, Catchall: true})
+ // The legacy row: the claim's own name, written before provenance.
+ if err := reg.UpsertAlias(registry.RouteAlias{Alias: "shop.example.com", Host: "acme.bab.example.net", Account: "bab", App: "shop", DNSStatus: "pointing"}); err != nil {
+ t.Fatal(err)
+ }
+ reconcile(t, s, reg, nil)
+ got := aliasMap(t, reg)
+ if a := got["shop.example.com"]; !a.Explicit || a.Host != "acme.bab.example.net" || a.DNSStatus != "pointing" {
+ t.Fatalf("claim-backed alias (a legacy row) = %+v, want explicit with its stamp kept", a)
+ }
+ for _, name := range []string{"acme.example.com", "beta.example.com"} {
+ a, ok := got[name]
+ if !ok || a.Explicit {
+ t.Fatalf("derived instance alias %s = %+v (listed %v), want not explicit", name, a, ok)
+ }
+ }
+ if a, ok := got["*.shop.example.org"]; !ok || a.Explicit {
+ t.Fatalf("synthetic catch-all row = %+v (listed %v), want not explicit", a, ok)
+ }
+}
+
+// The derived half's skip compares provenance too: a name whose exact claim
+// is detached and which the catch-all then derives to the SAME target and
+// owner is rewritten not explicit, so the fingerprint moves (the edge and the
+// edges file follow) and the next holding publication leaves it out.
+func TestDerivedAliasClearsExplicitOnTheSameTarget(t *testing.T) {
+ s := explicitStore(t)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bab.example.net", "acme", "bab", "shop")
+ putClaim(t, s, Claim{Host: "acme.example.com", Account: "bab", App: "shop", Route: "acme.bab.example.net"})
+ reconcile(t, s, reg, nil)
+ if a := aliasMap(t, reg)["acme.example.com"]; !a.Explicit || a.Host != "acme.bab.example.net" {
+ t.Fatalf("with its exact claim, acme.example.com = %+v, want explicit", a)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ deleteClaim(t, s, "acme.example.com")
+ reconcile(t, s, reg, nil)
+ a, ok := aliasMap(t, reg)["acme.example.com"]
+ if !ok || a.Explicit || a.Host != "acme.bab.example.net" || a.Account != "bab" || a.App != "shop" {
+ t.Fatalf("detached, then derived to the same target: acme.example.com = %+v (listed %v), want not explicit", a, ok)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after == before {
+ t.Fatal("the provenance change left the fingerprint unchanged")
+ }
+ // Steady state: the derived row is now the row wanted, so a further pass
+ // writes nothing and the fingerprint stays.
+ reconcile(t, s, reg, nil)
+ if again, _ := reg.Fingerprint(); again != after {
+ t.Fatal("a steady derived pass moved the fingerprint")
+ }
+}
+
+// A catch-all whose instances cannot be listed keeps its existing rows as
+// they are for the pass, but not a provenance no listed claim backs: an
+// explicit row left by a detached exact claim is kept and rewritten derived.
+func TestKeptDerivedAliasLosesAStaleExplicitMark(t *testing.T) {
+ dir := explicitStore(t).(*release.DirStore)
+ reg := aliasRegWith(t)
+ addInstance(t, reg, "acme.bab.example.net", "acme", "bab", "shop")
+ putClaim(t, dir, Claim{Host: "acme.example.com", Account: "bab", App: "shop", Route: "acme.bab.example.net"})
+ reconcile(t, dir, reg, nil)
+ if a := aliasMap(t, reg)["acme.example.com"]; !a.Explicit {
+ t.Fatalf("precondition: acme.example.com = %+v, want explicit", a)
+ }
+ deleteClaim(t, dir, "acme.example.com")
+ failing := listFailStore{dir, instanceRecordsPrefix("bab", "shop")}
+ if err := ReconcileAliases(context.Background(), failing, reg, aliasNow(), nil, nil); err != nil {
+ t.Fatal(err)
+ }
+ a, ok := aliasMap(t, reg)["acme.example.com"]
+ if !ok || a.Explicit || a.Host != "acme.bab.example.net" {
+ t.Fatalf("kept through a listing failure: acme.example.com = %+v (listed %v), want kept, not explicit", a, ok)
+ }
+}
diff --git a/internal/edge/holding.go b/internal/edge/holding.go
new file mode 100644
index 000000000..574eed74b
--- /dev/null
+++ b/internal/edge/holding.go
@@ -0,0 +1,28 @@
+package edge
+
+// HoldSnapshot is one published revision of a box's offline view, as the
+// local edge receives it (self-serve moves plan 3): the offline moves the
+// box's last record listing carried, by host. internal/host's offline view
+// publishes it; this package never imports internal/host, so the snapshot
+// crosses the boundary as this plain value.
+//
+// Rev increases by one with every publication, a refresh or a failure.
+// Failed means the listing failed: Holds is nil and no local hold is
+// believed until a listing reads again. The value is immutable once
+// delivered: its receiver may keep it.
+type HoldSnapshot struct {
+ Rev uint64
+ Failed bool
+ Holds map[string]HoldState
+}
+
+// HoldState is one host's offline move as the holding page needs it: the
+// move id a held row's hold op must equal, the account's display name, when
+// the account went offline (unix seconds, 0 when unknown) and the estimate
+// of how long the move takes (seconds, 0 when none).
+type HoldState struct {
+ MoveID string
+ DisplayName string
+ OfflineAt int64
+ EstimateSeconds int
+}
diff --git a/internal/edgesfile/eligible.go b/internal/edgesfile/eligible.go
new file mode 100644
index 000000000..be531e2dd
--- /dev/null
+++ b/internal/edgesfile/eligible.go
@@ -0,0 +1,32 @@
+package edgesfile
+
+import (
+ "strings"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// AliasPublishable is the edges-file writer's one rule for publishing an
+// alias row under its own name (self-serve moves plan 3 Task 1), shared by
+// the served aliases the writer lists in hosts, the explicit aliases it
+// lists in a held row's holding entry, and the edge's own local holds, so
+// the three cannot disagree:
+//
+// - a.Alias, the published name, is not empty and not a "*.<parent>"
+// catch-all (a remote edge has no certificate for a delegated wildcard
+// parent, so a catch-all is served on its home box only);
+// - its DNS is stamped "pointing" at the deployment's edges (attach needs
+// no DNS proof, so an unchecked name would hand a tenant an unbounded
+// set of names the shared issuer orders for);
+// - primaryServed: the alias's primary row (a.Host) is in the writer's
+// served set, or is a held row the writer publishes a holding entry for;
+// - not shadowed: no served row owns the name itself (a route always
+// wins, as the edge's Reload rules).
+//
+// There is no owner parameter: ownership is domains.ReconcileAliases' gate,
+// and a row that exists has passed it. An owner projection that is empty (a
+// hostname whose prefixes span apps) still publishes; the edge-local door
+// refuses it, as for any ownerless name.
+func AliasPublishable(a registry.RouteAlias, primaryServed, shadowed bool) bool {
+ return a.Alias != "" && !strings.HasPrefix(a.Alias, "*.") && a.DNSStatus == "pointing" && primaryServed && !shadowed
+}
diff --git a/internal/edgesfile/eligible_test.go b/internal/edgesfile/eligible_test.go
new file mode 100644
index 000000000..452cfa4ed
--- /dev/null
+++ b/internal/edgesfile/eligible_test.go
@@ -0,0 +1,37 @@
+package edgesfile
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestAliasPublishable(t *testing.T) {
+ pointing := registry.RouteAlias{Alias: "shop.example.com", Host: "shop.bab.example.net", Account: "bab", App: "shop", DNSStatus: "pointing"}
+ with := func(f func(*registry.RouteAlias)) registry.RouteAlias {
+ a := pointing
+ f(&a)
+ return a
+ }
+ for _, c := range []struct {
+ name string
+ a registry.RouteAlias
+ served, shadow bool
+ want bool
+ }{
+ {"pointing at a served primary", pointing, true, false, true},
+ {"absent primary", pointing, false, false, false},
+ {"a catch-all name", with(func(a *registry.RouteAlias) { a.Alias = "*.example.com" }), true, false, false},
+ {"an empty name", with(func(a *registry.RouteAlias) { a.Alias = "" }), true, false, false},
+ {"not pointing", with(func(a *registry.RouteAlias) { a.DNSStatus = "elsewhere" }), true, false, false},
+ {"never checked", with(func(a *registry.RouteAlias) { a.DNSStatus = "" }), true, false, false},
+ {"shadowed by a served row", pointing, true, true, false},
+ // Ownership is the reconciler's: a row that exists passed it, so the
+ // row's own columns (and the owner projection) are not consulted.
+ {"pointing with an empty owner projection", with(func(a *registry.RouteAlias) { a.Account, a.App = "", "" }), true, false, true},
+ } {
+ if got := AliasPublishable(c.a, c.served, c.shadow); got != c.want {
+ t.Errorf("%s: AliasPublishable = %v, want %v", c.name, got, c.want)
+ }
+ }
+}
diff --git a/internal/edgesfile/testdata/offline-moves.json b/internal/edgesfile/testdata/offline-moves.json
new file mode 100644
index 000000000..9a31f311b
--- /dev/null
+++ b/internal/edgesfile/testdata/offline-moves.json
@@ -0,0 +1,44 @@
+{
+ "v": 1,
+ "box": "annie",
+ "eip": "1.2.3.4",
+ "hibernation_configured": false,
+ "serves_shared_pool": false,
+ "accepts_inbound": false,
+ "box_id": "annie",
+ "moves_protocol": 0,
+ "seeds_protocol": 0,
+ "generation": "2026-10-24T09:00:00Z",
+ "hosts": {
+ "*.certonly.example.net": {
+ "role": "wildcard",
+ "platform": false,
+ "wildcard": true
+ },
+ "shop.example.com": {
+ "role": "home",
+ "platform": true,
+ "account": "bab",
+ "app": "shop",
+ "incarnation": 2,
+ "serving_seq": 3
+ }
+ },
+ "offline_moves": [
+ {
+ "host": "cart.bab.example.net",
+ "platform": true,
+ "account": "bab",
+ "app": "shop",
+ "aliases": [
+ "cart.example.com"
+ ],
+ "move_id": "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "incarnation": 1,
+ "serving_seq": 1,
+ "offline_at": 1792832400,
+ "estimate_seconds": 600,
+ "display_name": "Shop"
+ }
+ ]
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 0d3d1a126..9e2dd6985 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -824,7 +824,7 @@ func runAgent(args []string, buildClaims ClaimFactory) {
}
go retentionLoop(reg, collectBuildStates, boxJudge)
go registrySnapshotLoop(reg)
- go edgesFileLoop(reg, lifetimes)
+ go edgesFileLoop(reg, lifetimes, armedOfflineView)
// Region HA enrollment evidence (spec 1 §5.1): once, at start, and only
// with CARLOS_REGION_HA on. See boxrecord.go.
go boxRecordLoop()
diff --git a/internal/host/boxaddress_test.go b/internal/host/boxaddress_test.go
index 7ead461cc..b9a7b21c4 100644
--- a/internal/host/boxaddress_test.go
+++ b/internal/host/boxaddress_test.go
@@ -160,13 +160,13 @@ func TestEdgesFilePublishesOnlyWhatTheSourceReports(t *testing.T) {
var last []byte
ident := edgesFile{Box: "annie", BoxID: "annie"}
// The loop's own reading (the eip argument) is not what is published.
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "198.51.100.1", &last, false, claims, false); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "198.51.100.1", &last, false, claims, false, nil); err != nil {
t.Fatal(err)
}
if f := readEdgesFile(t, s, "annie"); f.EIP != "203.0.113.9" || f.PrivateIP != "10.0.1.5" || f.PrivateCIDR != "10.0.0.0/16" {
t.Fatalf("first publication: %+v", f)
}
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims, false); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims, false, nil); err != nil {
t.Fatal(err)
}
if f := readEdgesFile(t, s, "annie"); f.EIP != "" || f.PrivateIP != "10.0.1.5" {
@@ -184,10 +184,10 @@ func TestEdgesFileWithRegionHAOffKeepsTheEIPHold(t *testing.T) {
seedRoute(t, reg, "app.example.com")
var last []byte
ident := edgesFile{Box: "annie"}
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "203.0.113.9", &last, false, nil, false); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "203.0.113.9", &last, false, nil, false, nil); err != nil {
t.Fatal(err)
}
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, nil, false); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, nil, false, nil); err != nil {
t.Fatal(err)
}
if f := readEdgesFile(t, s, "annie"); f.EIP != "203.0.113.9" || f.PrivateIP != "" || f.PrivateCIDR != "" {
@@ -277,7 +277,7 @@ func TestEdgesFileUnknownAddressesLeaveThePublishedFileStanding(t *testing.T) {
claims.addrs = src
var last []byte
ident := edgesFile{Box: "annie", BoxID: "annie"}
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims, false); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, false, claims, false, nil); err != nil {
t.Fatal(err)
}
before, _, err := s.Get(context.Background(), edgesFileKey("annie"))
@@ -286,7 +286,7 @@ func TestEdgesFileUnknownAddressesLeaveThePublishedFileStanding(t *testing.T) {
}
lastBefore := string(last)
// A forced timed pass, which would rewrite an unchanged body.
- err = edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims, false)
+ err = edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims, false, nil)
if !errors.Is(err, errBoxAddressesUnknown) {
t.Fatalf("unknown read: err %v, want errBoxAddressesUnknown", err)
}
@@ -301,7 +301,7 @@ func TestEdgesFileUnknownAddressesLeaveThePublishedFileStanding(t *testing.T) {
t.Fatalf("published file lost its addresses: %+v", f)
}
// The retry reads again and publishes what it now reports.
- if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims, false); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), s, reg, ident, "", &last, true, claims, false, nil); err != nil {
t.Fatal(err)
}
if f := readEdgesFile(t, s, "annie"); f.PrivateIP != "10.0.1.6" {
diff --git a/internal/host/e2e_bridge.go b/internal/host/e2e_bridge.go
index ee5530161..f239bd638 100644
--- a/internal/host/e2e_bridge.go
+++ b/internal/host/e2e_bridge.go
@@ -931,7 +931,13 @@ func (lw *landWorld) publishErr(ctx context.Context, b *offBox) error {
SecretsKeyFP: edgesFileSecretsFP(b.getenv),
Capabilities: withOfflineCapability(edgesFileCapabilities(b.getenv), b.w.cfg.OfflineCapable && b.w.cfg.Offline != nil),
}
- return edgesFileWriteClaims(ctx, lw.wd.s, b.reg, ident, "192.0.2.1", &b.last, false, nil, b.w.cfg.Judge != nil)
+ // The holding source is the box's armed offline view, as the loop's
+ // armedOfflineView answers it: only a capable box has one.
+ var view *offlineView
+ if b.w.cfg.OfflineCapable {
+ view = b.w.cfg.Offline
+ }
+ return edgesFileWriteClaims(ctx, lw.wd.s, b.reg, ident, "192.0.2.1", &b.last, false, nil, b.w.cfg.Judge != nil, holdsOf(view))
}
// tick is the agent's order on one box: reconcile, the edges file, the
diff --git a/internal/host/edgesclaims_test.go b/internal/host/edgesclaims_test.go
index 7c41f0490..cf2aabec9 100644
--- a/internal/host/edgesclaims_test.go
+++ b/internal/host/edgesclaims_test.go
@@ -57,7 +57,7 @@ func publishWithClaims(t *testing.T, reg *registry.Registry, claims *edgesClaims
}
st := release.NewDirStore(t.TempDir())
var last []byte
- if err := edgesFileWriteClaims(context.Background(), st, reg, edgesFile{Box: "annie", BoxID: "annie"}, "1.2.3.4", &last, false, claims, false); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), st, reg, edgesFile{Box: "annie", BoxID: "annie"}, "1.2.3.4", &last, false, claims, false, nil); err != nil {
t.Fatal(err)
}
body, _, err := st.Get(context.Background(), edgesFileKey("annie"))
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go
index 64d277b19..a4c7b1db2 100644
--- a/internal/host/edgesfile.go
+++ b/internal/host/edgesfile.go
@@ -15,7 +15,9 @@ import (
"time"
"github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/edgesfile"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -376,8 +378,42 @@ type edgesFile struct {
Generation string `json:"generation"`
Hosts map[string]edgeHost `json:"hosts"`
+ // OfflineMoves are this box's held offline-move rows (self-serve moves
+ // plan 3, design §6.4): each answers the moving host and its explicit
+ // aliases with the holding page on every edge. Published only by a box
+ // with lifetimes on; separate from Hosts so no reader counts a held
+ // row as served. Additive (omitempty): a box holding nothing, and a
+ // claims-on box, publishes the file it always has.
+ OfflineMoves []holdingEntry `json:"offline_moves,omitempty"`
}
+// holdingEntry is one held row in edgesFile.OfflineMoves. Account and App
+// are the row's owner projection (servedOwners over the row and its path
+// prefixes), empty when they disagree. Aliases are the row's explicit
+// aliases the writer would publish for it as a served route
+// (edgesfile.AliasPublishable), sorted. MoveID is the offline move holding
+// the row, Incarnation and ServingSeq the row's lifetime pair (the hold's).
+// OfflineAt (unix seconds), EstimateSeconds and DisplayName are the
+// self-serve move's, zero for an operator's move.
+type holdingEntry struct {
+ Host string `json:"host"`
+ Platform bool `json:"platform,omitempty"`
+ Account string `json:"account,omitempty"`
+ App string `json:"app,omitempty"`
+ Aliases []string `json:"aliases,omitempty"`
+ MoveID string `json:"move_id"`
+ Incarnation uint64 `json:"incarnation,omitempty"`
+ ServingSeq uint64 `json:"serving_seq,omitempty"`
+ OfflineAt int64 `json:"offline_at,omitempty"`
+ EstimateSeconds int `json:"estimate_seconds,omitempty"`
+ DisplayName string `json:"display_name,omitempty"`
+}
+
+// holdingSource answers which offline move the box's last record listing
+// carried for host (offlineView.holding, read through holdsOf for one
+// pass): false when it carried none.
+type holdingSource func(host string) (provision.MoveState, bool)
+
// edgesFilePass builds this box's edges file from the routes currently in
// reg (every SERVED row on this box, every SERVING kind — the split between
// those belongs to the reader, Task 3, not to this writer; the split between
@@ -427,7 +463,7 @@ func edgesFilePass(ctx context.Context, s release.Store, reg *registry.Registry,
// period stale. On every other pass a headroom change alone is not a
// reason to write.
func edgesFileWrite(ctx context.Context, s release.Store, reg *registry.Registry, ident edgesFile, eip string, last *[]byte, refresh bool) error {
- return edgesFileWriteClaims(ctx, s, reg, ident, eip, last, refresh, nil, false)
+ return edgesFileWriteClaims(ctx, s, reg, ident, eip, last, refresh, nil, false, nil)
}
// errBoxAddressesUnknown fails an edges file pass whose address source could
@@ -442,7 +478,13 @@ var errBoxAddressesUnknown = errors.New("edges file: the box's addresses could n
// served row publishes its lifetime pair and every alias and served
// catch-all its primary's; off, no entry carries a pair. target_host is
// published either way.
-func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Registry, ident edgesFile, eip string, last *[]byte, refresh bool, claims *edgesClaims, lifetimes bool) error {
+//
+// holds is the box's offline view (holdsOf): with lifetimes on, every held
+// row whose hold op is the move the view knows for its host is published in
+// OfflineMoves (holdingEntries). Off, or with no source, none is: the
+// claims-on writer publishes no holding entry. Region HA makes no
+// difference.
+func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Registry, ident edgesFile, eip string, last *[]byte, refresh bool, claims *edgesClaims, lifetimes bool, holds holdingSource) error {
box := ident.Box
routes, err := reg.List()
if err != nil {
@@ -507,6 +549,12 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
// its claim names, stamped with the installation. Before owners and
// aliases, so an alias of a withheld host is withheld with it.
applyEdgesClaims(ctx, claims, routes, hosts)
+ // The served rows, before any alias or catch-all joins the map: an
+ // alias they name is shadowed (a route always wins).
+ servedRows := make(map[string]bool, len(hosts))
+ for host := range hosts {
+ servedRows[host] = true
+ }
// Owners (#175 P7): computed over the served rows alone, before any alias
// or catch-all joins the map, so an alias can only ever inherit a route's.
paths, err := reg.ListPaths()
@@ -549,7 +597,8 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
if a.Alias == "" || strings.HasPrefix(a.Alias, "*.") {
continue
}
- if _, served := hosts[a.Host]; !served {
+ _, served := hosts[a.Host]
+ if !served {
continue
}
if a.DNSStatus != "pointing" {
@@ -563,7 +612,9 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
continue
}
withheldAliasSaid.Delete(a.Alias)
- if _, taken := hosts[a.Alias]; taken {
+ // The one rule (edgesfile.AliasPublishable), shared with the
+ // holding entries below and the edge's local holds.
+ if !edgesfile.AliasPublishable(a, served, servedRows[a.Alias]) {
continue
}
// The edge serves an alias with its primary's own entry, prefix map
@@ -673,6 +724,11 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
Incarnation: inc, ServingSeq: seq, TargetHost: target}
}
+ var offline []holdingEntry
+ if lifetimes && holds != nil {
+ offline = holdingEntries(routes, paths, aliases, servedRows, holds)
+ }
+
var private BoxAddresses
if claims != nil && claims.addrs != nil {
// Region HA spec 2 §6 (A1-12): publish only what the box's address
@@ -697,6 +753,7 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
f := ident
f.V, f.EIP, f.Hosts, f.Generation = 1, eip, hosts, ""
+ f.OfflineMoves = offline
f.PrivateIP, f.PrivateCIDR = private.Private, private.PrivateCIDR
gateCopy := f
gateCopy.Headroom = nil // a changing reading is not, by itself, a reason to write
@@ -720,6 +777,67 @@ func edgesFileWriteClaims(ctx context.Context, s release.Store, reg *registry.Re
return nil
}
+// holdingEntries are the held rows the writer publishes (self-serve moves
+// plan 3 Task 1, design §6.4): a row held (registry.IsHold) under an op that
+// is the id of the move holds knows for its host, one entry each, sorted by
+// host. A ready row publishes none, and neither does a held row whose op the
+// view does not know (no move, or another move's id): the entry stops when
+// the record no longer carries that move or the row turns ready. A target's
+// landing row therefore publishes from the flip until ReleaseHoldSeq. A
+// sidecar is never published, held or not.
+//
+// Aliases are the row's explicit alias rows that edgesfile.AliasPublishable
+// admits with the held row as their primary (primaryServed: this is a held
+// row the writer publishes) and servedRows as the shadowing set. Derived
+// aliases (an instance catch-all's names, the synthetic "*.<host>" row) are
+// never listed: they answer as today during a move.
+func holdingEntries(routes []registry.Route, paths []registry.RoutePath, aliases []registry.RouteAlias, servedRows map[string]bool, holds holdingSource) []holdingEntry {
+ held := map[string]edgeHost{}
+ moves := map[string]provision.MoveState{}
+ for _, r := range routes {
+ if !registry.IsHold(r.Status) || r.HoldOp == "" || r.IsSidecar() {
+ continue
+ }
+ mv, ok := holds(r.Host)
+ if !ok || mv.ID != r.HoldOp {
+ continue
+ }
+ held[r.Host] = edgeHost{}
+ moves[r.Host] = mv
+ }
+ if len(held) == 0 {
+ return nil
+ }
+ owners := servedOwners(routes, held, paths)
+ byHost := map[string][]string{}
+ for _, a := range aliases {
+ if _, ok := held[a.Host]; !ok || !a.Explicit {
+ continue
+ }
+ if edgesfile.AliasPublishable(a, true, servedRows[a.Alias]) {
+ byHost[a.Host] = append(byHost[a.Host], a.Alias)
+ }
+ }
+ var out []holdingEntry
+ for _, r := range routes {
+ if _, ok := held[r.Host]; !ok {
+ continue
+ }
+ mv := moves[r.Host]
+ state := holdStateOf(mv)
+ e := holdingEntry{Host: r.Host, Platform: r.Platform || r.Provisioned, MoveID: mv.ID,
+ Incarnation: r.Incarnation, ServingSeq: r.ServingSeq,
+ OfflineAt: state.OfflineAt, EstimateSeconds: state.EstimateSeconds, DisplayName: state.DisplayName}
+ e.Account, e.App = owners[r.Host].owner()
+ if names := byHost[r.Host]; len(names) > 0 {
+ e.Aliases = slices.Sorted(slices.Values(names))
+ }
+ out = append(out, e)
+ }
+ slices.SortFunc(out, func(a, b holdingEntry) int { return strings.Compare(a.Host, b.Host) })
+ return out
+}
+
// seedEdgesFileGate reads this box's last-published edges file at loop arm
// and returns the change-gate body (Generation cleared) to prime *last,
// mirroring seedSnapshotFingerprint's contract exactly (snapshot_loop.go):
@@ -843,7 +961,15 @@ func edgesFileSecretsFP(getenv func(string) string) string {
//
// lifetimes is the box's lifetimes switch, runAgent's boxJudge != nil: the
// writer publishes the lifetime pair exactly when the box judges lifetimes.
-func edgesFileLoop(reg *registry.Registry, lifetimes bool) {
+//
+// offline is the armed moves worker's offline view (armedOfflineView), read
+// each tick because the worker arms after the loop starts; nil until then,
+// and on a box that cannot take part in an offline move. With lifetimes on,
+// the view's holds are the writer's holding source, and their digest joins
+// the fingerprint the schedule watches, so a hold the view learns after the
+// row turned held (a moves pass's listing lands after the reconcile pass
+// that held the row) is published on the next tick, not the next timer.
+func edgesFileLoop(reg *registry.Registry, lifetimes bool, offline func() *offlineView) {
ctx := context.Background()
if shouldSkipPublisherElection(os.Getenv) {
log.Printf("agent: edges file loop is a platform-box job — skipped on a fleet box")
@@ -914,7 +1040,14 @@ func edgesFileLoop(reg *registry.Registry, lifetimes bool) {
// EC2 each read can block for the probe's whole timeout.
eip := readPublicIP()
for now := range tick.C {
+ var view *offlineView
+ if lifetimes && offline != nil {
+ view = offline()
+ }
fp, err := reg.Fingerprint()
+ if err == nil && view != nil {
+ fp += "\x1d" + view.snapshot().digest()
+ }
if err != nil {
// Not fatal: the timer still publishes. Counted and throttled the
// way edge.Watch counts its own poll errors.
@@ -935,7 +1068,7 @@ func edgesFileLoop(reg *registry.Registry, lifetimes bool) {
ident.Headroom = headroomFor(headroomCapabilities(ident.Capabilities), dataDir, reg)
ident = withMovesFields(ident, dataDir, optDir)
ident = withSeedsFields(ident, dataDir, optDir)
- err = edgesFileWriteClaims(ctx, store, reg, ident, eip, &last, state.timed(), claims, lifetimes)
+ err = edgesFileWriteClaims(ctx, store, reg, ident, eip, &last, state.timed(), claims, lifetimes, holdsOf(view))
if err != nil {
// Throttled: a bucket that refuses every write (an IAM Deny, an
// outage) is retried on a backoff, and each retry must not be a
diff --git a/internal/host/edgesfile_holding_e2e_test.go b/internal/host/edgesfile_holding_e2e_test.go
new file mode 100644
index 000000000..818554997
--- /dev/null
+++ b/internal/host/edgesfile_holding_e2e_test.go
@@ -0,0 +1,64 @@
+//go:build linux
+
+package host
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// The holding entries through the real move (self-serve moves plan 3 Task
+// 1): TestOfflineMoveE2E's world with lifetimes, the console's doors, both
+// boxes ticked in the agent's order and each box's edges file written by the
+// production writer, its holding source the box's own offline view. The
+// source publishes the host as a holding entry from its hold until its row
+// goes at finish; the target from its landing row until open releases it,
+// when it publishes the host as a served route instead.
+func TestHoldingEntriesThroughAForwardMove(t *testing.T) {
+ w := newE2EWorld(t, landOpts{lifetime: true})
+ entry := func(b *offBox) (holdingEntry, bool) {
+ t.Helper()
+ var f edgesFile
+ if err := json.Unmarshal(w.published(b), &f); err != nil {
+ t.Fatal(err)
+ }
+ return holdingFor(f, offHost)
+ }
+ require := func(when string, b *offBox, want bool, pair registry.Pair) {
+ t.Helper()
+ e, ok := entry(b)
+ if ok != want {
+ t.Fatalf("%s: %s publishes a holding entry %v (%+v), want %v", when, b.name, ok, e, want)
+ }
+ if ok && (e.MoveID != w.id || e.Incarnation != pair.Incarnation || e.ServingSeq != pair.ServingSeq) {
+ t.Fatalf("%s: %s's holding entry = %+v, want move %s at %+v", when, b.name, e, w.id, pair)
+ }
+ if ok && b.lists(offHost) {
+ t.Fatalf("%s: %s publishes the host both held and served", when, b.name)
+ }
+ }
+ require("before the move", w.ie, false, registry.Pair{})
+ require("before the move", w.uk, false, registry.Pair{})
+ w.begin()
+ w.tickAll()
+ require("after start", w.ie, true, registry.Pair{Incarnation: 1, ServingSeq: 1})
+ require("after start", w.uk, false, registry.Pair{})
+ w.seal()
+ w.flipAndLand()
+ w.tickAll()
+ require("after the landing", w.ie, true, registry.Pair{Incarnation: 1, ServingSeq: 1})
+ require("after the landing", w.uk, true, registry.Pair{Incarnation: 1, ServingSeq: 1})
+ w.open()
+ w.tickAll()
+ require("after open", w.uk, false, registry.Pair{})
+ if !w.uk.lists(offHost) {
+ t.Fatal("after open: uk-box does not serve the host")
+ }
+ require("after open", w.ie, true, registry.Pair{Incarnation: 1, ServingSeq: 1})
+ w.finish("done")
+ w.tickAll()
+ require("after finish", w.ie, false, registry.Pair{})
+ require("after finish", w.uk, false, registry.Pair{})
+}
diff --git a/internal/host/edgesfile_holding_test.go b/internal/host/edgesfile_holding_test.go
new file mode 100644
index 000000000..05898d010
--- /dev/null
+++ b/internal/host/edgesfile_holding_test.go
@@ -0,0 +1,537 @@
+package host
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "go/ast"
+ "os"
+ "path/filepath"
+ "reflect"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The writer's holding entries (self-serve moves plan 3 Task 1): a box with
+// lifetimes on publishes each held offline-move row the offline view knows,
+// under its own move id, in offline_moves, separate from hosts, with the
+// explicit aliases the writer would publish for it as a served route.
+
+const (
+ holdMoveA = "mv_aaaaaaaaaaaaaaaaaaaaaaaaaa"
+ holdMoveB = "mv_bbbbbbbbbbbbbbbbbbbbbbbbbb"
+)
+
+var holdOfflineAt = time.Date(2026, 10, 24, 9, 0, 0, 0, time.UTC)
+
+// heldInstance upserts a provisioned instance row of account bab, app shop,
+// held under op at the lifetime pair (inc, seq). A fresh insert writes the
+// hold and the pair (registry.Upsert).
+func heldInstance(t *testing.T, reg *registry.Registry, host, status, op string, inc, seq uint64) {
+ t.Helper()
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Socket: "/run/carlos/" + host + ".sock",
+ Provisioned: true, Account: "bab", App: "shop", Status: status, HoldOp: op, Incarnation: inc, ServingSeq: seq}); err != nil {
+ t.Fatal(err)
+ }
+ if rt, err := reg.Get(host); err != nil || rt == nil || rt.Status != status || rt.HoldOp != op {
+ t.Fatalf("fixture: %s = %+v (%v), want %s under %s", host, rt, err, status, op)
+ }
+}
+
+func upsertAliases(t *testing.T, reg *registry.Registry, aliases ...registry.RouteAlias) {
+ t.Helper()
+ for _, a := range aliases {
+ if err := reg.UpsertAlias(a); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// selfServeMove is an offline move of id in state, carrying the self-serve
+// block the holding page reads.
+func selfServeMove(id, state string) provision.MoveState {
+ at := holdOfflineAt
+ return provision.MoveState{ID: id, Kind: provision.MoveKindOffline, State: state, To: "uk",
+ SelfServe: &provision.SelfServeMove{ID: id, DisplayName: "Shop", EstimateSeconds: 600, OfflineAt: &at}}
+}
+
+// viewOf is an offline view refreshed from records carrying moves (host ->
+// move), as a moves pass refreshes it from its listing.
+func viewOf(moves map[string]provision.MoveState) *offlineView {
+ v := newOfflineView(moveCfg{}, nil)
+ var recs []provision.Record
+ for host, m := range moves {
+ m := m
+ recs = append(recs, provision.Record{Host: host, Account: "bab", App: "shop", Move: &m})
+ }
+ v.refresh(recs, nil)
+ return v
+}
+
+// publishHolding runs the real writer once as annie and returns the raw
+// body and the file. claims nil is region HA off.
+func publishHolding(t *testing.T, reg *registry.Registry, claims *edgesClaims, lifetimes bool, holds holdingSource) ([]byte, edgesFile) {
+ t.Helper()
+ if claims != nil {
+ claims.addrs = staticAddresses{Public: "1.2.3.4"}
+ }
+ withheldAliasSaid = sync.Map{}
+ st := release.NewDirStore(t.TempDir())
+ var last []byte
+ if err := edgesFileWriteClaims(context.Background(), st, reg, edgesFile{Box: "annie", BoxID: "annie"}, "1.2.3.4", &last, false, claims, lifetimes, holds); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := st.Get(context.Background(), edgesFileKey("annie"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f edgesFile
+ if err := json.Unmarshal(body, &f); err != nil {
+ t.Fatal(err)
+ }
+ return body, f
+}
+
+func holdingFor(f edgesFile, host string) (holdingEntry, bool) {
+ for _, e := range f.OfflineMoves {
+ if e.Host == host {
+ return e, true
+ }
+ }
+ return holdingEntry{}, false
+}
+
+// holdingRegistry: shop.example.com served at (2, 3) with its explicit
+// pointing alias, and cart.bab.example.net held moving under holdMoveA at
+// (1, 1) with its explicit pointing alias.
+func holdingRegistry(t *testing.T) *registry.Registry {
+ t.Helper()
+ reg := edgesTestRegistry(t)
+ lifetimeRow(t, reg, "shop.example.com", 2, 3)
+ heldInstance(t, reg, "cart.bab.example.net", "moving", holdMoveA, 1, 1)
+ upsertAliases(t, reg,
+ registry.RouteAlias{Alias: "www.shop.example.com", Host: "shop.example.com", Account: "bab", App: "shop", DNSStatus: "pointing", Explicit: true},
+ registry.RouteAlias{Alias: "cart.example.com", Host: "cart.bab.example.net", Account: "bab", App: "shop", DNSStatus: "pointing", Explicit: true})
+ return reg
+}
+
+func TestWriterPublishesHoldingEntriesOnlyInLifetimesMode(t *testing.T) {
+ view := viewOf(map[string]provision.MoveState{"cart.bab.example.net": selfServeMove(holdMoveA, provision.OfflineSealing)})
+ want := holdingEntry{Host: "cart.bab.example.net", Platform: true, Account: "bab", App: "shop", Aliases: []string{"cart.example.com"},
+ MoveID: holdMoveA, Incarnation: 1, ServingSeq: 1, OfflineAt: holdOfflineAt.Unix(), EstimateSeconds: 600, DisplayName: "Shop"}
+ regionHA := func() *edgesClaims {
+ // shop.example.com's claim names another box: withheld here, with
+ // its alias (applyEdgesClaims runs before the served aliases).
+ book := &claimsBook{claims: map[string]*hostclaim.Claim{"shop.example.com": serving("bert", 42)}}
+ return newEdgesClaims("annie", book.read)
+ }
+ for _, c := range []struct {
+ name string
+ claims func() *edgesClaims
+ lifetimes bool
+ }{
+ {"lifetimes on, region HA off", func() *edgesClaims { return nil }, true},
+ {"lifetimes on, region HA on", regionHA, true},
+ {"lifetimes off, region HA off", func() *edgesClaims { return nil }, false},
+ {"lifetimes off, region HA on", regionHA, false},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ reg := holdingRegistry(t)
+ claims := c.claims()
+ body, f := publishHolding(t, reg, claims, c.lifetimes, view.holding)
+ if !c.lifetimes {
+ if bytes.Contains(body, []byte("offline_moves")) || len(f.OfflineMoves) != 0 {
+ t.Fatalf("lifetimes off published holding entries: %s", body)
+ }
+ } else if len(f.OfflineMoves) != 1 || !reflect.DeepEqual(f.OfflineMoves[0], want) {
+ t.Fatalf("holding entries = %+v, want [%+v]", f.OfflineMoves, want)
+ }
+ // The held row is never a served host, whatever the mode.
+ if _, ok := f.Hosts["cart.bab.example.net"]; ok {
+ t.Fatalf("the held row is published as served: %s", body)
+ }
+ if _, ok := f.Hosts["cart.example.com"]; ok {
+ t.Fatalf("the held row's alias is published as served: %s", body)
+ }
+ _, shop := f.Hosts["shop.example.com"]
+ _, www := f.Hosts["www.shop.example.com"]
+ if claims != nil {
+ if shop || www {
+ t.Fatalf("a withheld host or its alias is published: %s", body)
+ }
+ } else if !shop || !www {
+ t.Fatalf("the served row or its alias is missing: %s", body)
+ }
+ })
+ }
+ // No source (a box with no armed offline view): nothing, lifetimes on.
+ if _, f := publishHolding(t, holdingRegistry(t), nil, true, nil); len(f.OfflineMoves) != 0 {
+ t.Fatalf("a nil holding source published %+v", f.OfflineMoves)
+ }
+}
+
+func TestHoldingEntryListsOnlyExplicitPublishableAliases(t *testing.T) {
+ reg := edgesTestRegistry(t)
+ seedRoute(t, reg, "shop.example.com") // a served row
+ heldInstance(t, reg, "cart.bab.example.net", "moving", holdMoveA, 1, 1)
+ heldInstance(t, reg, "mixed.bab.example.net", "moving", holdMoveB, 1, 4)
+ // mixed's prefix serves another app: its owner projection is empty.
+ if err := reg.Upsert(registry.Route{Host: "blog.bab.example.net", Kind: "instance", Socket: "/run/carlos/blog.sock",
+ Provisioned: true, Account: "bab", App: "blog", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertPath(registry.RoutePath{Host: "mixed.bab.example.net", Prefix: "blog", Target: "blog.bab.example.net", Account: "bab", App: "shop"}); err != nil {
+ t.Fatal(err)
+ }
+ cart := func(name, dns string, explicit bool) registry.RouteAlias {
+ return registry.RouteAlias{Alias: name, Host: "cart.bab.example.net", Account: "bab", App: "shop", DNSStatus: dns, Explicit: explicit}
+ }
+ upsertAliases(t, reg,
+ cart("cart.example.com", "pointing", true), // listed
+ cart("www.cart.example.com", "pointing", true), // listed
+ cart("cart.example.org", "elsewhere", true), // explicit, not pointing
+ cart("cart.example.net", "pointing", false), // derived exact name
+ cart("shop.example.com", "pointing", true), // shadowed by a served row
+ cart("*.cart.example.com", "pointing", true), // the synthetic catch-all row
+ registry.RouteAlias{Alias: "mixed.example.com", Host: "mixed.bab.example.net", Account: "bab", App: "shop", DNSStatus: "pointing", Explicit: true},
+ )
+ view := viewOf(map[string]provision.MoveState{
+ "cart.bab.example.net": selfServeMove(holdMoveA, provision.OfflineSealing),
+ "mixed.bab.example.net": {ID: holdMoveB, Kind: provision.MoveKindOffline, State: provision.OfflineSealing},
+ })
+ body, f := publishHolding(t, reg, nil, true, view.holding)
+ if len(f.OfflineMoves) != 2 || f.OfflineMoves[0].Host != "cart.bab.example.net" || f.OfflineMoves[1].Host != "mixed.bab.example.net" {
+ t.Fatalf("holding entries = %+v, want cart then mixed", f.OfflineMoves)
+ }
+ if got := f.OfflineMoves[0].Aliases; !reflect.DeepEqual(got, []string{"cart.example.com", "www.cart.example.com"}) {
+ t.Fatalf("cart's aliases = %v, want only the explicit pointing unshadowed exact names", got)
+ }
+ mixed := f.OfflineMoves[1]
+ want := holdingEntry{Host: "mixed.bab.example.net", Platform: true, Aliases: []string{"mixed.example.com"}, MoveID: holdMoveB, Incarnation: 1, ServingSeq: 4}
+ if !reflect.DeepEqual(mixed, want) {
+ t.Fatalf("a mixed-app prefix host = %+v, want %+v (no owner, no self-serve block)", mixed, want)
+ }
+ // The served row keeps its own name: the shadowing alias is never served.
+ if h := f.Hosts["shop.example.com"]; h.Alias {
+ t.Fatalf("shop.example.com = %+v, want the route", h)
+ }
+ if bytes.Contains(body, []byte(`"cart.example.org"`)) || bytes.Contains(body, []byte(`"cart.example.net"`)) {
+ t.Fatalf("an ineligible alias reached the file: %s", body)
+ }
+
+ // TestDerivedAliasClearsExplicitOnTheSameTarget's publication half: the
+ // reconciler's rewrite of a detached explicit name to derived drops it
+ // from the next holding publication.
+ upsertAliases(t, reg, cart("www.cart.example.com", "pointing", false))
+ if _, f := publishHolding(t, reg, nil, true, view.holding); !reflect.DeepEqual(f.OfflineMoves[0].Aliases, []string{"cart.example.com"}) {
+ t.Fatalf("after the rewrite to derived, cart's aliases = %v", f.OfflineMoves[0].Aliases)
+ }
+}
+
+// The target's landing row publishes from the flip (its row is minted held
+// "landing") until ReleaseHoldSeq turns it ready, when it is a served route
+// at open's pair and no longer a holding entry.
+func TestTargetPublishesWhileLanding(t *testing.T) {
+ reg := edgesTestRegistry(t)
+ const host = "cart.bab.example.net"
+ view := viewOf(map[string]provision.MoveState{host: selfServeMove(holdMoveA, provision.OfflineLanding)})
+ // Before the flip the target has no row: nothing.
+ if _, f := publishHolding(t, reg, nil, true, view.holding); len(f.OfflineMoves) != 0 || len(f.Hosts) != 0 {
+ t.Fatalf("before the landing row: %+v", f)
+ }
+ heldInstance(t, reg, host, "landing", holdMoveA, 1, 1)
+ upsertAliases(t, reg, registry.RouteAlias{Alias: "cart.example.com", Host: host, Account: "bab", App: "shop", DNSStatus: "pointing", Explicit: true})
+ for _, state := range []string{provision.OfflineLanding, provision.OfflineLanded, provision.OfflineOpen} {
+ view = viewOf(map[string]provision.MoveState{host: selfServeMove(holdMoveA, state)})
+ _, f := publishHolding(t, reg, nil, true, view.holding)
+ e, ok := holdingFor(f, host)
+ if !ok || e.MoveID != holdMoveA || e.Incarnation != 1 || e.ServingSeq != 1 || !reflect.DeepEqual(e.Aliases, []string{"cart.example.com"}) {
+ t.Fatalf("%s: the landing row's entry = %+v (%v)", state, e, ok)
+ }
+ if _, served := f.Hosts[host]; served {
+ t.Fatalf("%s: the landing row is published as served", state)
+ }
+ }
+ if ok, err := reg.ReleaseHoldSeq(host, holdMoveA, registry.Pair{Incarnation: 1, ServingSeq: 2}); err != nil || !ok {
+ t.Fatalf("release: %v %v", ok, err)
+ }
+ _, f := publishHolding(t, reg, nil, true, view.holding)
+ if len(f.OfflineMoves) != 0 {
+ t.Fatalf("a ready row still publishes a holding entry: %+v", f.OfflineMoves)
+ }
+ wantEntry(t, "after the release", f.Hosts, host, 1, 2, "")
+ wantEntry(t, "after the release", f.Hosts, "cart.example.com", 1, 2, host)
+}
+
+func TestHoldingEntryStopsWhenTheRecordClearsOrChangesID(t *testing.T) {
+ reg := edgesTestRegistry(t)
+ const host = "cart.bab.example.net"
+ heldInstance(t, reg, host, "moving", holdMoveA, 1, 1)
+ entries := func(holds holdingSource) []holdingEntry {
+ _, f := publishHolding(t, reg, nil, true, holds)
+ return f.OfflineMoves
+ }
+ if got := entries(viewOf(map[string]provision.MoveState{host: selfServeMove(holdMoveA, provision.OfflineSealing)}).holding); len(got) != 1 {
+ t.Fatalf("the held row under the view's move: %+v", got)
+ }
+ // The record no longer carries a move: the view does not know it.
+ if got := entries(viewOf(nil).holding); len(got) != 0 {
+ t.Fatalf("a cleared record still publishes %+v", got)
+ }
+ // The record carries another move: the row's hold is not its.
+ if got := entries(viewOf(map[string]provision.MoveState{host: selfServeMove(holdMoveB, provision.OfflineSealing)}).holding); len(got) != 0 {
+ t.Fatalf("a move of another id publishes %+v", got)
+ }
+ // A failed listing knows no move.
+ failed := viewOf(map[string]provision.MoveState{host: selfServeMove(holdMoveA, provision.OfflineSealing)})
+ failed.fail()
+ if got := entries(failed.holding); len(got) != 0 {
+ t.Fatalf("a failed view publishes %+v", got)
+ }
+ // The row turns ready: none, whatever the view says.
+ if ok, err := reg.ReleaseHoldSeq(host, holdMoveA, registry.Pair{Incarnation: 1, ServingSeq: 2}); err != nil || !ok {
+ t.Fatalf("release: %v %v", ok, err)
+ }
+ if got := entries(viewOf(map[string]provision.MoveState{host: selfServeMove(holdMoveA, provision.OfflineSealing)}).holding); len(got) != 0 {
+ t.Fatalf("a ready row publishes %+v", got)
+ }
+}
+
+// A move without a self-serve block (an operator's) publishes zero
+// estimate fields; a self-serve block with no offline time publishes 0.
+func TestHoldingEntryWithoutTheSelfServeBlock(t *testing.T) {
+ reg := edgesTestRegistry(t)
+ heldInstance(t, reg, "cart.bab.example.net", "moving", holdMoveA, 1, 1)
+ mv := selfServeMove(holdMoveA, provision.OfflineSealing)
+ mv.SelfServe.OfflineAt = nil
+ _, f := publishHolding(t, reg, nil, true, viewOf(map[string]provision.MoveState{"cart.bab.example.net": mv}).holding)
+ if len(f.OfflineMoves) != 1 || f.OfflineMoves[0].OfflineAt != 0 || f.OfflineMoves[0].DisplayName != "Shop" || f.OfflineMoves[0].EstimateSeconds != 600 {
+ t.Fatalf("no offline time: %+v", f.OfflineMoves)
+ }
+ zero := time.Time{}
+ mv.SelfServe.OfflineAt = &zero
+ if _, f := publishHolding(t, reg, nil, true, viewOf(map[string]provision.MoveState{"cart.bab.example.net": mv}).holding); f.OfflineMoves[0].OfflineAt != 0 {
+ t.Fatalf("a zero offline time published %d", f.OfflineMoves[0].OfflineAt)
+ }
+ mv.SelfServe = nil
+ _, f = publishHolding(t, reg, nil, true, viewOf(map[string]provision.MoveState{"cart.bab.example.net": mv}).holding)
+ if len(f.OfflineMoves) != 1 || f.OfflineMoves[0].OfflineAt != 0 || f.OfflineMoves[0].DisplayName != "" || f.OfflineMoves[0].EstimateSeconds != 0 {
+ t.Fatalf("an operator move: %+v", f.OfflineMoves)
+ }
+}
+
+func TestViewSnapshotIsImmutableAndRevisioned(t *testing.T) {
+ const host = "cart.bab.example.net"
+ v := newOfflineView(moveCfg{}, nil)
+ if v.snapshot() != nil {
+ t.Fatal("an unbuilt view has a snapshot")
+ }
+ var got []edge.HoldSnapshot
+ v.Subscribe(func(s edge.HoldSnapshot) { got = append(got, s) })
+ if len(got) != 0 {
+ t.Fatalf("subscription before any publication delivered %+v", got)
+ }
+
+ mvA := selfServeMove(holdMoveA, provision.OfflineSealing)
+ recs := []provision.Record{{Host: host, Account: "bab", App: "shop", Move: &mvA}}
+ v.refresh(recs, nil)
+ s1 := v.snapshot()
+ // The listing's own values change after it is published: the snapshot
+ // does not.
+ mvA.SelfServe.DisplayName = "Changed"
+ mvA.ID = holdMoveB
+ v.fail()
+ mvB := selfServeMove(holdMoveB, provision.OfflineLanded)
+ v.refresh([]provision.Record{{Host: "other.bab.example.net", Account: "bab", App: "shop", Move: &mvB}}, nil)
+
+ if s1.Rev != 1 || s1.Failed || s1.Holds[host].ID != holdMoveA || s1.Holds[host].SelfServe.DisplayName != "Shop" {
+ t.Fatalf("snapshot 1 changed under its reader: %+v", s1)
+ }
+ if s3 := v.snapshot(); s3.Rev != 3 || s3.Failed || len(s3.Holds) != 1 {
+ t.Fatalf("snapshot 3 = %+v", s3)
+ }
+ if _, ok := v.holding(host); ok {
+ t.Fatal("holding reads an old snapshot")
+ }
+ if mv, ok := v.holding("other.bab.example.net"); !ok || mv.ID != holdMoveB {
+ t.Fatalf("holding(other) = %+v %v", mv, ok)
+ }
+ if len(got) != 3 || got[0].Rev != 1 || got[1].Rev != 2 || got[2].Rev != 3 {
+ t.Fatalf("onChange received %+v, want revisions 1, 2, 3", got)
+ }
+ wantA := edge.HoldState{MoveID: holdMoveA, DisplayName: "Shop", OfflineAt: holdOfflineAt.Unix(), EstimateSeconds: 600}
+ if got[0].Failed || !reflect.DeepEqual(got[0].Holds, map[string]edge.HoldState{host: wantA}) {
+ t.Fatalf("publication 1 delivered %+v", got[0])
+ }
+ if !got[1].Failed || got[1].Holds != nil {
+ t.Fatalf("the failure delivered %+v, want failed with no holds", got[1])
+ }
+ if got[2].Failed || got[2].Holds["other.bab.example.net"].MoveID != holdMoveB {
+ t.Fatalf("publication 3 delivered %+v", got[2])
+ }
+ // A later subscriber is handed the current snapshot first.
+ var late []edge.HoldSnapshot
+ v.Subscribe(func(s edge.HoldSnapshot) { late = append(late, s) })
+ if len(late) != 1 || late[0].Rev != 3 {
+ t.Fatalf("a late subscriber received %+v, want revision 3", late)
+ }
+
+ // Two publishers never share a revision, and onChange sees them in order.
+ var mu sync.Mutex
+ var revs []uint64
+ v.Subscribe(func(s edge.HoldSnapshot) { mu.Lock(); revs = append(revs, s.Rev); mu.Unlock() })
+ var wg sync.WaitGroup
+ for i := range 2 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for range 50 {
+ if i == 0 {
+ v.refresh(recs, nil)
+ } else {
+ v.fail()
+ }
+ }
+ }()
+ }
+ wg.Wait()
+ if len(revs) != 101 {
+ t.Fatalf("%d deliveries, want 101", len(revs))
+ }
+ for i, r := range revs {
+ if r != uint64(3+i) {
+ t.Fatalf("delivery %d has revision %d, want %d (%v)", i, r, 3+i, revs)
+ }
+ }
+
+ // The moves pass's listing error publishes a failed snapshot.
+ w := newMovesWorker(moveCfg{Offline: viewOf(map[string]provision.MoveState{host: mvB})})
+ before := w.cfg.Offline.snapshot()
+ st := failingListStore{Store: release.NewDirStore(t.TempDir()), prefix: "apps/", err: errors.New("listing refused")}
+ if err := movesPass(context.Background(), st, w); err == nil {
+ t.Fatal("the pass's listing did not fail")
+ }
+ after := w.cfg.Offline.snapshot()
+ if !after.Failed || after.Holds != nil || after.Rev != before.Rev+1 {
+ t.Fatalf("after the pass's failed listing: %+v (before %+v)", after, before)
+ }
+ // A worker with no view is tolerated, as before.
+ if err := movesPass(context.Background(), st, newMovesWorker(moveCfg{})); err == nil {
+ t.Fatal("the pass's listing did not fail")
+ }
+}
+
+// The edges-file loop runs a pass when the holds the writer reads change,
+// not on every republication of the same holds: the loop's due fingerprint
+// carries the snapshot's digest, which a new revision of the same contents
+// leaves alone.
+func TestHoldingDigestWakesTheEdgesFileLoop(t *testing.T) {
+ const host = "cart.bab.example.net"
+ v := newOfflineView(moveCfg{}, nil)
+ empty := v.snapshot().digest()
+ mv := selfServeMove(holdMoveA, provision.OfflineSealing)
+ v.refresh([]provision.Record{{Host: host, Move: &mv}}, nil)
+ one := v.snapshot().digest()
+ v.refresh([]provision.Record{{Host: host, Move: &mv}}, nil)
+ if again := v.snapshot().digest(); again != one {
+ t.Fatal("a republication of the same holds changed the digest")
+ }
+ if one == empty {
+ t.Fatal("a new hold left the digest unchanged")
+ }
+ changed := mv
+ changed.SelfServe = &provision.SelfServeMove{DisplayName: "Shop", EstimateSeconds: 900, OfflineAt: mv.SelfServe.OfflineAt}
+ v.refresh([]provision.Record{{Host: host, Move: &changed}}, nil)
+ if v.snapshot().digest() == one {
+ t.Fatal("a changed estimate left the digest unchanged")
+ }
+ v.fail()
+ if v.snapshot().digest() == one || v.snapshot().digest() == empty {
+ t.Fatal("a failed snapshot digests as a built one")
+ }
+}
+
+// internal/edgesfile/testdata/offline-moves.json is this writer's output
+// for one served route, one holding entry for another host with one alias
+// and one certificate-only wildcard entry (generation pinned), which the
+// edge's decoder test reads (plan 3 Task 2).
+func TestHoldingFixtureIsTheWritersOutput(t *testing.T) {
+ reg := edgesTestRegistry(t)
+ lifetimeRow(t, reg, "shop.example.com", 2, 3)
+ heldInstance(t, reg, "cart.bab.example.net", "moving", holdMoveA, 1, 1)
+ upsertAliases(t, reg, registry.RouteAlias{Alias: "cart.example.com", Host: "cart.bab.example.net", Account: "bab", App: "shop", DNSStatus: "pointing", Explicit: true})
+ if err := reg.UpsertWildcardDomain(registry.WildcardDomain{Host: "certonly.example.net", Account: "bab", App: "shop"}); err != nil {
+ t.Fatal(err)
+ }
+ view := viewOf(map[string]provision.MoveState{"cart.bab.example.net": selfServeMove(holdMoveA, provision.OfflineSealing)})
+ _, f := publishHolding(t, reg, nil, true, view.holding)
+ f.Generation = "2026-10-24T09:00:00Z"
+ got, err := json.MarshalIndent(f, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got = append(got, '\n')
+ path := filepath.Join("..", "edgesfile", "testdata", "offline-moves.json")
+ if os.Getenv("CARLOS_UPDATE_FIXTURES") == "1" {
+ if err := os.WriteFile(path, got, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ want, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("%s is not the writer's output (CARLOS_UPDATE_FIXTURES=1 rewrites it):\n%s", path, got)
+ }
+ if len(f.Hosts) != 2 || f.Hosts["*.certonly.example.net"].Role != "wildcard" || len(f.OfflineMoves) != 1 || len(f.OfflineMoves[0].Aliases) != 1 {
+ t.Fatalf("fixture shape: %s", got)
+ }
+}
+
+// The production wiring: runAgent hands the edges file loop the armed
+// worker's offline view, and the loop hands the writer that view's holds,
+// read once per pass (holdsOf).
+func TestHoldingSourceIsWiredIntoTheEdgesFileLoop(t *testing.T) {
+ handed := false
+ inspectFunc(t, "agent.go", "runAgent", func(n ast.Node) {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return
+ }
+ if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "edgesFileLoop" && len(call.Args) == 3 {
+ if a, ok := call.Args[2].(*ast.Ident); ok && a.Name == "armedOfflineView" {
+ handed = true
+ }
+ }
+ })
+ if !handed {
+ t.Error("runAgent's edgesFileLoop call does not pass armedOfflineView")
+ }
+ passed := false
+ inspectFunc(t, "edgesfile.go", "edgesFileLoop", func(n ast.Node) {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return
+ }
+ if id, ok := call.Fun.(*ast.Ident); !ok || id.Name != "edgesFileWriteClaims" || len(call.Args) == 0 {
+ return
+ }
+ if h, ok := call.Args[len(call.Args)-1].(*ast.CallExpr); ok {
+ if id, ok := h.Fun.(*ast.Ident); ok && id.Name == "holdsOf" {
+ passed = true
+ }
+ }
+ })
+ if !passed {
+ t.Error("edgesFileLoop does not hand the writer holdsOf(view)")
+ }
+}
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go
index f094a810f..82b763a94 100644
--- a/internal/host/edgesfile_test.go
+++ b/internal/host/edgesfile_test.go
@@ -1648,7 +1648,7 @@ func publishLifetimes(t *testing.T, reg *registry.Registry, box string, claims *
}
st := release.NewDirStore(t.TempDir())
var last []byte
- if err := edgesFileWriteClaims(context.Background(), st, reg, edgesFile{Box: box, BoxID: box}, "1.2.3.4", &last, false, claims, lifetimes); err != nil {
+ if err := edgesFileWriteClaims(context.Background(), st, reg, edgesFile{Box: box, BoxID: box}, "1.2.3.4", &last, false, claims, lifetimes, nil); err != nil {
t.Fatal(err)
}
body, _, err := st.Get(context.Background(), edgesFileKey(box))
@@ -1986,10 +1986,11 @@ func TestEdgesFileLoopTakesTheJudgesLifetimesSwitch(t *testing.T) {
if !ok {
return
}
- if id, ok := call.Fun.(*ast.Ident); !ok || id.Name != "edgesFileWriteClaims" || len(call.Args) == 0 {
+ if id, ok := call.Fun.(*ast.Ident); !ok || id.Name != "edgesFileWriteClaims" || len(call.Args) < 2 {
return
}
- if a, ok := call.Args[len(call.Args)-1].(*ast.Ident); ok && a.Name == "lifetimes" {
+ // lifetimes, then the holding source (self-serve moves plan 3).
+ if a, ok := call.Args[len(call.Args)-2].(*ast.Ident); ok && a.Name == "lifetimes" {
passed = true
}
})
diff --git a/internal/host/movesoffline.go b/internal/host/movesoffline.go
index b91258e94..a9290f16a 100644
--- a/internal/host/movesoffline.go
+++ b/internal/host/movesoffline.go
@@ -17,6 +17,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancectl"
"github.com/carlosframework/platform/internal/layout"
@@ -210,6 +211,136 @@ type offlineView struct {
written func(host string)
snap atomic.Pointer[offlineSnapshot]
logged sync.Map // host -> time.Time: the last unreadable-record line
+
+ // The holds the edges-file writer and the local edge read (self-serve
+ // moves plan 3 Task 1): an immutable, revisioned snapshot, published
+ // (by refresh and fail) under pubMu, so a revision is allocated and
+ // stored in one critical section and no two publishers share one.
+ // onChange, installed by Subscribe, is called inside pubMu after every
+ // publication with the snapshot it published.
+ pubMu sync.Mutex
+ holds atomic.Pointer[offlineHolds]
+ onChange func(edge.HoldSnapshot)
+}
+
+// offlineHolds is one publication of the view's holds: Holds is the
+// offline moves the listing carried, by host (nil when Failed). Immutable
+// once published: a reader holding one keeps reading the same holds.
+type offlineHolds struct {
+ Rev uint64
+ Holds map[string]provision.MoveState
+ Failed bool
+}
+
+// holding is the move h knows for host; false when h is nil (nothing
+// published), failed, or knows no offline move for host.
+func (h *offlineHolds) holding(host string) (provision.MoveState, bool) {
+ if h == nil || h.Failed {
+ return provision.MoveState{}, false
+ }
+ mv, ok := h.Holds[host]
+ return mv, ok
+}
+
+// digest is what the writer reads from h, in a stable form: the edges-file
+// loop runs a pass when it changes (a new move, a cleared one, a changed
+// estimate, a failure), and not when only the revision does.
+func (h *offlineHolds) digest() string {
+ switch {
+ case h == nil:
+ return "holds:none"
+ case h.Failed:
+ return "holds:failed"
+ }
+ hosts := make([]string, 0, len(h.Holds))
+ for host := range h.Holds {
+ hosts = append(hosts, host)
+ }
+ sort.Strings(hosts)
+ var b strings.Builder
+ b.WriteString("holds:")
+ for _, host := range hosts {
+ s := holdStateOf(h.Holds[host])
+ fmt.Fprintf(&b, "%s\x1f%s\x1f%s\x1f%d\x1f%d\x1e", host, s.MoveID, s.DisplayName, s.OfflineAt, s.EstimateSeconds)
+ }
+ return b.String()
+}
+
+// holdStateOf is the holding page's view of mv: its id and, for a
+// self-serve move, the display name, the offline time in unix seconds (0
+// when absent or zero) and the estimate.
+func holdStateOf(mv provision.MoveState) edge.HoldState {
+ s := edge.HoldState{MoveID: mv.ID}
+ if ss := mv.SelfServe; ss != nil {
+ s.DisplayName, s.EstimateSeconds = ss.DisplayName, ss.EstimateSeconds
+ if ss.OfflineAt != nil && !ss.OfflineAt.IsZero() {
+ s.OfflineAt = ss.OfflineAt.Unix()
+ }
+ }
+ return s
+}
+
+// adapt is h as the edge receives it.
+func (h *offlineHolds) adapt() edge.HoldSnapshot {
+ s := edge.HoldSnapshot{Rev: h.Rev, Failed: h.Failed}
+ if h.Holds != nil {
+ s.Holds = make(map[string]edge.HoldState, len(h.Holds))
+ for host, mv := range h.Holds {
+ s.Holds[host] = holdStateOf(mv)
+ }
+ }
+ return s
+}
+
+// publish stores keep's snapshot and the next revision of the holds, and
+// hands the holds to onChange, all under pubMu, so the two views of one
+// listing are stored together.
+func (v *offlineView) publish(keep *offlineSnapshot, holds map[string]provision.MoveState, failed bool) {
+ v.pubMu.Lock()
+ defer v.pubMu.Unlock()
+ v.snap.Store(keep)
+ next := &offlineHolds{Holds: holds, Failed: failed}
+ if prev := v.holds.Load(); prev != nil {
+ next.Rev = prev.Rev + 1
+ } else {
+ next.Rev = 1
+ }
+ v.holds.Store(next)
+ if v.onChange != nil {
+ v.onChange(next.adapt())
+ }
+}
+
+// snapshot is the current publication of the holds; nil before the first.
+func (v *offlineView) snapshot() *offlineHolds { return v.holds.Load() }
+
+// holding is the writer's holdingSource over the current snapshot.
+func (v *offlineView) holding(host string) (provision.MoveState, bool) {
+ return v.snapshot().holding(host)
+}
+
+// Subscribe installs fn as the view's one onChange and, in the same
+// critical section, delivers the current snapshot to it when one has been
+// published: a publication before the subscription is seen, and one racing
+// it is delivered exactly once, in revision order. fn runs under pubMu and
+// must not publish or subscribe.
+func (v *offlineView) Subscribe(fn func(edge.HoldSnapshot)) {
+ v.pubMu.Lock()
+ defer v.pubMu.Unlock()
+ v.onChange = fn
+ if cur := v.holds.Load(); cur != nil && fn != nil {
+ fn(cur.adapt())
+ }
+}
+
+// holdsOf is the writer's holdingSource for one pass: v's current snapshot,
+// read once, so every row of the pass is judged against one publication.
+// nil (no view) publishes no holding entry.
+func holdsOf(v *offlineView) holdingSource {
+ if v == nil {
+ return nil
+ }
+ return v.snapshot().holding
}
type offlineSnapshot struct {
@@ -235,10 +366,23 @@ func newOfflineView(cfg moveCfg, written func(host string)) *offlineView {
// most once a minute each.
func (v *offlineView) refresh(recs []provision.Record, unreadable map[string]bool) {
next := &offlineSnapshot{moves: map[string]provision.MoveState{}, owners: map[string][2]string{}, unreadable: map[string]bool{}}
+ holds := map[string]provision.MoveState{}
for _, rec := range recs {
if rec.Move.Offline() {
next.moves[rec.Host] = *rec.Move
next.owners[rec.Host] = [2]string{rec.Account, rec.App}
+ // The published holds own their self-serve block: the listing's
+ // records are the caller's, and a snapshot is immutable.
+ held := *rec.Move
+ if held.SelfServe != nil {
+ ss := *held.SelfServe
+ if ss.OfflineAt != nil {
+ at := *ss.OfflineAt
+ ss.OfflineAt = &at
+ }
+ held.SelfServe = &ss
+ }
+ holds[rec.Host] = held
}
}
now := time.Now()
@@ -249,12 +393,13 @@ func (v *offlineView) refresh(recs []provision.Record, unreadable map[string]boo
log.Printf("moves: offline view: %s: the record does not decode; its row is kept until it does", host)
}
}
- v.snap.Store(next)
+ v.publish(next, holds, false)
}
-// fail records a listing that could not be read, before any was: keep
-// answers true for every host until a listing is read.
-func (v *offlineView) fail() { v.snap.Store(&offlineSnapshot{failed: true}) }
+// fail records a listing that could not be read (at agent start, or a moves
+// pass's): keep answers true for every host until a listing is read, and the
+// holds publish a failed snapshot, which knows no move.
+func (v *offlineView) fail() { v.publish(&offlineSnapshot{failed: true}, nil, true) }
// built reports whether any listing, read or failed, has been recorded.
func (v *offlineView) built() bool { return v.snap.Load() != nil }
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 601a5f021..ca882cd5a 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -1164,6 +1164,12 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error {
// Judged, a record its slot no longer vouches for is not listed.
recs, unreadable, err := provision.ListAllRecordsStrictJudged(ctx, s, cfg.Judge)
if err != nil {
+ // The offline view records the failure (movesoffline.go): its holds
+ // publish a failed snapshot, so no holding entry outlives a listing
+ // that could not be read, and keep fails closed until one reads.
+ if cfg.Offline != nil {
+ cfg.Offline.fail()
+ }
return fmt.Errorf("moves: list records: %w", err)
}
var targets map[string]edgesFile // lazy: most passes carry no move
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 214dd9ed2..ace1ca59a 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -1374,6 +1374,21 @@ func TestPlan14RootReadCensus(t *testing.T) {
// bootstrap page) and SelfServeRecoveryPending (the bounded context) in
// place, which moves it again.
seal = "f7f1a9f9e33fac5247c181ac88a6888a33be2793fbb68ca5392f6bae1afffeb7"
+ // Self-serve moves plan 3 (holding entries, the holding page, probe mode
+ // and the alias rule): verify the preceding seal first. All rows await a
+ // human ruling under rev3=PROPOSED-self-serve-moves-3.
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ if err = loadRootReadInventory(root+"/internal/provision/testdata/plan14-root-reads-self-serve-moves-3.tsv", &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ // Task 1 (alias provenance and the writer's holding entries) re-pins
+ // seven accepted-legacy units (the route_aliases schema, its scanner,
+ // writer and fingerprint; the reconciler's two provenance writers; the
+ // edges file loop) and freezes two new ones (keepDerived and
+ // edgesfile.AliasPublishable), which moves the seal.
+ seal = "272a2c07e3ed3e8df91d45561b002492f3f358527011339659043c2a137a80fd"
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index 02f093061..c1e45ae28 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -369,7 +369,7 @@ func TestPlan9B8RecordWriteCensus(t *testing.T) {
if len(got) < 100 {
t.Fatalf("census found only %d units under %s; the walk is broken", len(got), root)
}
- inventory := plan14SelfServeMoves2WriteCensusInventory()
+ inventory := plan14SelfServeMoves3WriteCensusInventory()
problems := append(censusDiff(inventory, got), censusPolicy(inventory)...)
if len(problems) > 0 {
t.Fatalf("record-write census rejected %d site(s); update the explicit inventory only after reviewing the source:\n %s", len(problems), strings.Join(problems, "\n "))
diff --git a/internal/provision/self_serve_moves3_census_test.go b/internal/provision/self_serve_moves3_census_test.go
new file mode 100644
index 000000000..883d475e2
--- /dev/null
+++ b/internal/provision/self_serve_moves3_census_test.go
@@ -0,0 +1,40 @@
+package provision
+
+// plan14SelfServeMoves3WriteCensusInventory is the record-write inventory for
+// the self-serve moves plan 3 (holding entries, the holding page, probe mode
+// and the alias rule), built over plan 2's inventory without editing it.
+// Marked PROPOSED-self-serve-moves-3
+// (docs/superpowers/notes/census-proposal-self-serve-moves-3.md); not an
+// accepted ruling. Later tasks of the plan extend this function.
+//
+// Task 1 (alias provenance and the writer's holding entries): no record
+// write is added, moved or made raw, and no unit of this census changes.
+// - The edges-file writer (internal/host/edgesfile.go:edgesFileWriteClaims)
+// changes shape: it publishes the held rows as offline_moves holding
+// entries beside hosts. Its one bucket write is still putReplacing's
+// (snapshot_loop.go, nonrecord sel:Put, unchanged) to
+// control/edges/<box>.json, never a record key, so neither it nor the
+// new holdingEntries is a census unit.
+// - The registry's route_aliases.explicit and the domains reconciler's
+// provenance writes are SQLite, not bucket writes.
+// - The e2e bridge's landWorld.publishErr hands the writer the box's
+// offline view; it writes nothing itself.
+func plan14SelfServeMoves3WriteCensusInventory() []censusEntry {
+ removed := []struct{ file, unit string }{}
+ added := []censusEntry{}
+ entries := plan14SelfServeMoves2WriteCensusInventory()
+ for _, r := range removed {
+ found := false
+ for i, entry := range entries {
+ if entry.File == r.file && entry.Unit == r.unit {
+ entries = append(entries[:i], entries[i+1:]...)
+ found = true
+ break
+ }
+ }
+ if !found {
+ panic("self-serve moves 3 removal of an unlisted unit " + r.file + ":" + r.unit)
+ }
+ }
+ return append(entries, added...)
+}
diff --git a/internal/provision/testdata/plan14-root-reads-self-serve-moves-3.tsv b/internal/provision/testdata/plan14-root-reads-self-serve-moves-3.tsv
new file mode 100644
index 000000000..42fd91c5d
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-self-serve-moves-3.tsv
@@ -0,0 +1,29 @@
+# Census overlay self-serve-moves-3, marked rev3=PROPOSED-self-serve-moves-3 (not an accepted ruling; docs/superpowers/notes/census-proposal-self-serve-moves-3.md). Loaded after the self-serve-moves-2 overlay.
+# Task 1 (alias provenance and the writer's holding entries), rev3=PROPOSED-self-serve-moves-3: no record envelope changes and no root read, record read or task is added. The registry gains route_aliases.explicit (RouteAlias.Explicit, the tracked field Explicit in @fields below): the schema, its scanner, writer and fingerprint are re-pinned. The domains reconciler writes provenance (ReconcileAliases and deriveInstanceAliases re-pinned; keepDerived new). The edges-file writer publishes held rows as offline_moves holding entries (edgesFileWriteClaims re-pinned at F,J1; holdingEntries new at F,J1; edgesfile.AliasPublishable new) and its loop and runAgent wire the offline view in. Seven accepted-legacy units take new frozen fingerprints and two new ones are frozen, which moves the seal. Envelopes re-pinned: internal/domains, internal/edge (holding.go: the HoldSnapshot and HoldState DTOs, no read), internal/edgesfile (eligible.go), internal/host (edgesfile.go, movesoffline.go, movespass.go, agent.go, e2e_bridge.go) and internal/registry.
+@legacy 652c416a0cd27a153fa259d76ab20538480e2c3afd98cbec6af083902b17f5ff internal/domains/aliases.go ReconcileAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its fast-converge and instances loops: self-serve moves 1's body (rev3=J1, the catch-all's labels counted through domains.InstanceLabelCountsJudged), which now writes each alias row's provenance: an exact claim on the row's own name upserts it explicit (every pass, so a legacy row is upgraded on the first), and the synthetic "*.<host>" catch-all row not explicit; registry read sites unchanged, offsets moved; a report the edges file lists holding aliases by, not a decision registry:Account@15605,registry:Account@15618,registry:Alias@15220,registry:Alias@15260,registry:Alias@15330,registry:App@12165,registry:App@12365,registry:App@12521,registry:App@12722,registry:App@15631,registry:App@15640,registry:App@2026,registry:App@2222,registry:App@6036,registry:App@6235,registry:Host@15590,registry:Host@15879,registry:Host@15932,registry:Host@16004,registry:Host@16516,registry:Host@16608,registry:Host@16667,registry:Host@16748,registry:Host@406,registry:Prefix@16524,registry:Prefix@16622,registry:Prefix@16675,registry:Prefix@16756,registry:Status@8899,whole:Route@12134,whole:Route@12209,whole:Route@12356,whole:Route@12418,whole:Route@12561,whole:Route@12717,whole:Route@13613,whole:Route@1369,whole:Route@14825,whole:Route@14833,whole:Route@2066,whole:Route@2217,whole:Route@2273,whole:Route@359,whole:Route@388,whole:Route@399,whole:Route@417,whole:Route@5382,whole:Route@6080,whole:Route@6226,whole:Route@8229,whole:Route@8681,whole:Route@8843,whole:RouteAlias@14841,whole:RouteAlias@15195,whole:RoutePath@10426,whole:RoutePath@16409,whole:RoutePath@16451,whole:RoutePath@16565,whole:RoutePath@16595,whole:WildcardDomain@12933,whole:WildcardDomain@15520,whole:WildcardDomain@15569,whole:WildcardDomain@15732,whole:WildcardDomain@15838,whole:WildcardDomain@15867 edef8a1bf06cab5666a52ec19840c54f02913ddc67a32da7f6197db1606556a4
+internal/domains/aliases.go ReconcileAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos), from its fast-converge and instances loops: self-serve moves 1's body (rev3=J1, the catch-all's labels counted through domains.InstanceLabelCountsJudged), which now writes each alias row's provenance: an exact claim on the row's own name upserts it explicit (every pass, so a legacy row is upgraded on the first), and the synthetic "*.<host>" catch-all row not explicit; registry read sites unchanged, offsets moved; a report the edges file lists holding aliases by, not a decision registry:Account@15605,registry:Account@15618,registry:Alias@15220,registry:Alias@15260,registry:Alias@15330,registry:App@12165,registry:App@12365,registry:App@12521,registry:App@12722,registry:App@15631,registry:App@15640,registry:App@2026,registry:App@2222,registry:App@6036,registry:App@6235,registry:Host@15590,registry:Host@15879,registry:Host@15932,registry:Host@16004,registry:Host@16516,registry:Host@16608,registry:Host@16667,registry:Host@16748,registry:Host@406,registry:Prefix@16524,registry:Prefix@16622,registry:Prefix@16675,registry:Prefix@16756,registry:Status@8899,whole:Route@12134,whole:Route@12209,whole:Route@12356,whole:Route@12418,whole:Route@12561,whole:Route@12717,whole:Route@13613,whole:Route@1369,whole:Route@14825,whole:Route@14833,whole:Route@2066,whole:Route@2217,whole:Route@2273,whole:Route@359,whole:Route@388,whole:Route@399,whole:Route@417,whole:Route@5382,whole:Route@6080,whole:Route@6226,whole:Route@8229,whole:Route@8681,whole:Route@8843,whole:RouteAlias@14841,whole:RouteAlias@15195,whole:RoutePath@10426,whole:RoutePath@16409,whole:RoutePath@16451,whole:RoutePath@16565,whole:RoutePath@16595,whole:WildcardDomain@12933,whole:WildcardDomain@15520,whole:WildcardDomain@15569,whole:WildcardDomain@15732,whole:WildcardDomain@15838,whole:WildcardDomain@15867 edef8a1bf06cab5666a52ec19840c54f02913ddc67a32da7f6197db1606556a4
+@legacy 887eab39c9584891268b9afedcd81c995849a97545258b3eacc506b5c0165b23 internal/domains/aliases.go deriveInstanceAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): self-serve moves 1's body, whose already-the-row-we-want skip now also compares the existing row's provenance (registry:Explicit, one new site): a derived name left explicit by a detached exact claim on the same target and owner is rewritten not explicit, and both keep-as-it-is paths (a listing failure, an unjudged label) hand the kept row to keepDerived; the other registry read sites unchanged, offsets moved; no new route registry:Account@2163,registry:Account@3516,registry:Account@4587,registry:Alias@2225,registry:Alias@2293,registry:Alias@2337,registry:Alias@557,registry:App@1686,registry:App@2189,registry:App@3542,registry:App@4613,registry:Explicit@4632,registry:Host@1843,registry:Host@4566,registry:Host@4576,registry:Host@4787,registry:Provisioned@1636,registry:Replica@1672,registry:Slug@1775,whole:Route@1620,whole:Route@1726,whole:Route@3058,whole:RouteAlias@2143,whole:RouteAlias@2377,whole:RouteAlias@3498,whole:RouteAlias@3867,whole:RouteAlias@4548,whole:RouteAlias@509,whole:RouteAlias@539,whole:RouteAlias@552,whole:RouteAlias@568 42d5a40cccb38c31f306b49c00fde7482e4ee0b3cbf6a6a2dfbaac06592ef883
+internal/domains/aliases.go deriveInstanceAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): self-serve moves 1's body, whose already-the-row-we-want skip now also compares the existing row's provenance (registry:Explicit, one new site): a derived name left explicit by a detached exact claim on the same target and owner is rewritten not explicit, and both keep-as-it-is paths (a listing failure, an unjudged label) hand the kept row to keepDerived; the other registry read sites unchanged, offsets moved; no new route registry:Account@2163,registry:Account@3516,registry:Account@4587,registry:Alias@2225,registry:Alias@2293,registry:Alias@2337,registry:Alias@557,registry:App@1686,registry:App@2189,registry:App@3542,registry:App@4613,registry:Explicit@4632,registry:Host@1843,registry:Host@4566,registry:Host@4576,registry:Host@4787,registry:Provisioned@1636,registry:Replica@1672,registry:Slug@1775,whole:Route@1620,whole:Route@1726,whole:Route@3058,whole:RouteAlias@2143,whole:RouteAlias@2377,whole:RouteAlias@3498,whole:RouteAlias@3867,whole:RouteAlias@4548,whole:RouteAlias@509,whole:RouteAlias@539,whole:RouteAlias@552,whole:RouteAlias@568 42d5a40cccb38c31f306b49c00fde7482e4ee0b3cbf6a6a2dfbaac06592ef883
+@legacy d9e54f55922dc009be69d6d13ac95bb4209bf520f8366ce643ce8df7ee48a653 internal/domains/aliases.go keepDerived legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): new, deriveInstanceAliases' provenance half of keeping a catch-all's existing row for a pass: reads the kept row's Alias and Explicit and, when no listed exact claim backs an explicit row, upserts the same row not explicit (target, owner and stamps unchanged); no file, record, release or claim read registry:Alias@116,registry:Alias@257,registry:Explicit@140,registry:Explicit@95,whole:RouteAlias@186 888039b448e6f976d3603c644b7b3d383bb342ed7a5008074b3e9b1e3d8be90b
+internal/domains/aliases.go keepDerived legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): new, deriveInstanceAliases' provenance half of keeping a catch-all's existing row for a pass: reads the kept row's Alias and Explicit and, when no listed exact claim backs an explicit row, upserts the same row not explicit (target, owner and stamps unchanged); no file, record, release or claim read registry:Alias@116,registry:Alias@257,registry:Explicit@140,registry:Explicit@95,whole:RouteAlias@186 888039b448e6f976d3603c644b7b3d383bb342ed7a5008074b3e9b1e3d8be90b
+@legacy 21e0a71fec437d44da824257175004f9a51e771f9fc3beb7f3081b1bf50919e7 internal/edgesfile/eligible.go AliasPublishable legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): new, the edges-file writer's one alias publication rule (a non-empty, non-catch-all name stamped pointing, its primary served or a held row the writer publishes, not shadowed by a served row), shared by the served aliases, the holding entries and (plan 3 Task 2) the edge's local holds; a pure predicate over one registry alias row; no file, record, release or claim read registry:Alias@126,registry:Alias@90,registry:DNSStatus@144 f62afa4edc8bccc41563f5843391ee13730d11430601469414bff07f3654874a
+internal/edgesfile/eligible.go AliasPublishable legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=root-route; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): new, the edges-file writer's one alias publication rule (a non-empty, non-catch-all name stamped pointing, its primary served or a held row the writer publishes, not shadowed by a served row), shared by the served aliases, the holding entries and (plan 3 Task 2) the edge's local holds; a pure predicate over one registry alias row; no file, record, release or claim read registry:Alias@126,registry:Alias@90,registry:DNSStatus@144 f62afa4edc8bccc41563f5843391ee13730d11430601469414bff07f3654874a
+@legacy 73aa8aadc0b840c6558d128d749b06948799dfa515a8b92ff8780da7b98c1439 internal/host/edgesfile.go edgesFileLoop legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): self-serve moves 1's body, which also takes the armed worker's offline view (runAgent's armedOfflineView, read each tick), folds its holds' digest into the fingerprint the schedule watches with lifetimes on, and hands the writer that view's holds for the pass (holdsOf); read site unchanged, offset moved; a report, not a decision; self-serve moves 3 Task 1 file:Read@2786 42d5ba7d2903b7abc45c5c5be3bc7cbc36e5b7cb19416fe708139e6f100d3781
+internal/host/edgesfile.go edgesFileLoop legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): self-serve moves 1's body, which also takes the armed worker's offline view (runAgent's armedOfflineView, read each tick), folds its holds' digest into the fingerprint the schedule watches with lifetimes on, and hands the writer that view's holds for the pass (holdsOf); read site unchanged, offset moved; a report, not a decision; self-serve moves 3 Task 1 file:Read@2786 42d5ba7d2903b7abc45c5c5be3bc7cbc36e5b7cb19416fe708139e6f100d3781
+@legacy d414eb2469658072e579263b4326223edb1241b6373782e0dbb9ce18cdd422dd internal/registry/registry.go Registry.Fingerprint legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the registry's SQLite side, as carlos: in the agent (/opt/carlos/carlos agent, carlos-edge.service) and in the registry child (carlos __registry-child, which a root pass forks as the service user); the remote arm, unchanged, in the process that holds a remote handle: self-serve moves 1's body with route_aliases.explicit folded into the alias part, only when set, so a registry with no explicit row digests as before; self-serve moves 3 Task 1 file:Exec@284,file:Open@188,file:Query@3389,file:Query@368,file:Query@3868,file:Query@5040,file:Query@5894,file:Query@6806,registry:ClaimIncarnation@1158,registry:ClaimIncarnation@2297,registry:ClaimIncarnation@2374,registry:ClaimOwner@1202,registry:ClaimOwner@2498,registry:ClaimOwner@2563,registry:ClaimRowID@1221,registry:ClaimRowID@2590,registry:ClaimRowID@2656,registry:ClaimStamp@1183,registry:ClaimStamp@2407,registry:ClaimStamp@2471,registry:Incarnation@1240,registry:Incarnation@2877,registry:Incarnation@2941,registry:ServingSeq@1259,registry:ServingSeq@2968,registry:ServingSeq@3031,registry:Text@158,registry:path@207,registry:remote@55,registry:remote@87,whole:remoteClient@57,whole:remoteClient@89 cb12369e81d06da6e48b6a4ed6720dfec2a9401c366f99920bbf5343489876b5
+internal/registry/registry.go Registry.Fingerprint legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the registry's SQLite side, as carlos: in the agent (/opt/carlos/carlos agent, carlos-edge.service) and in the registry child (carlos __registry-child, which a root pass forks as the service user); the remote arm, unchanged, in the process that holds a remote handle: self-serve moves 1's body with route_aliases.explicit folded into the alias part, only when set, so a registry with no explicit row digests as before; self-serve moves 3 Task 1 file:Exec@284,file:Open@188,file:Query@3389,file:Query@368,file:Query@3868,file:Query@5040,file:Query@5894,file:Query@6806,registry:ClaimIncarnation@1158,registry:ClaimIncarnation@2297,registry:ClaimIncarnation@2374,registry:ClaimOwner@1202,registry:ClaimOwner@2498,registry:ClaimOwner@2563,registry:ClaimRowID@1221,registry:ClaimRowID@2590,registry:ClaimRowID@2656,registry:ClaimStamp@1183,registry:ClaimStamp@2407,registry:ClaimStamp@2471,registry:Incarnation@1240,registry:Incarnation@2877,registry:Incarnation@2941,registry:ServingSeq@1259,registry:ServingSeq@2968,registry:ServingSeq@3031,registry:Text@158,registry:path@207,registry:remote@55,registry:remote@87,whole:remoteClient@57,whole:remoteClient@89 cb12369e81d06da6e48b6a4ed6720dfec2a9401c366f99920bbf5343489876b5
+@legacy 87b681f434357ff61191af763ffcc91f73615b2ff811274383ced07e41fe4b9f internal/registry/registry.go Registry.ListAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the registry's SQLite side, as carlos: in the agent (/opt/carlos/carlos agent, carlos-edge.service) and in the registry child (carlos __registry-child, which a root pass forks as the service user); the remote arm, unchanged: rev3=J1,J4's route-and-input body, which also scans route_aliases.explicit into RouteAlias.Explicit; self-serve moves 3 Task 1 file:Query@199,registry:Account@508,registry:Alias@489,registry:Aliases@164,registry:App@520,registry:CertExpiresAt@562,registry:CreatedAt@580,registry:DNSCheckedAt@545,registry:DNSStatus@528,registry:Explicit@594,registry:Host@499,registry:remote@61,registry:remote@93,registry:sql@199,whole:RouteAlias@168,whole:RouteAlias@645,whole:RouteAlias@658,whole:RouteAlias@663,whole:RouteAlias@677,whole:remoteClient@63,whole:remoteClient@95 1de0ab15f79d83aba3aa1db193d332fb67ed82b0dfd2cbc8bc4a9e7df592280b
+internal/registry/registry.go Registry.ListAliases legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the registry's SQLite side, as carlos: in the agent (/opt/carlos/carlos agent, carlos-edge.service) and in the registry child (carlos __registry-child, which a root pass forks as the service user); the remote arm, unchanged: rev3=J1,J4's route-and-input body, which also scans route_aliases.explicit into RouteAlias.Explicit; self-serve moves 3 Task 1 file:Query@199,registry:Account@508,registry:Alias@489,registry:Aliases@164,registry:App@520,registry:CertExpiresAt@562,registry:CreatedAt@580,registry:DNSCheckedAt@545,registry:DNSStatus@528,registry:Explicit@594,registry:Host@499,registry:remote@61,registry:remote@93,registry:sql@199,whole:RouteAlias@168,whole:RouteAlias@645,whole:RouteAlias@658,whole:RouteAlias@663,whole:RouteAlias@677,whole:remoteClient@63,whole:remoteClient@95 1de0ab15f79d83aba3aa1db193d332fb67ed82b0dfd2cbc8bc4a9e7df592280b
+@legacy ed68f74772a1e2eaf67174c91e9695cdd6dc3d8ff390d53da3148ed612b39470 internal/registry/registry.go Registry.UpsertAlias legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the registry's SQLite side, as carlos: in the agent (/opt/carlos/carlos agent, carlos-edge.service) and in the registry child (carlos __registry-child, which a root pass forks as the service user); the remote arm, unchanged: rev3=J1,J4's route-and-input body, which also writes route_aliases.explicit from RouteAlias.Explicit on insert and in the conflict clause (explicit=excluded.explicit), the DNS and certificate stamps still preserved; self-serve moves 3 Task 1 file:Exec@183,registry:Account@497,registry:Alias@480,registry:App@508,registry:CertExpiresAt@544,registry:CreatedAt@561,registry:DNSCheckedAt@528,registry:DNSStatus@515,registry:Explicit@574,registry:Host@489,registry:remote@57,registry:remote@87,registry:sql@183,whole:RouteAlias@143,whole:RouteAlias@146,whole:remoteClient@59,whole:remoteClient@89 820523f13ffc70b1704122ab9a386a7b962edbd581e172ccd52dc7c87ff4d4ff
+internal/registry/registry.go Registry.UpsertAlias legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the registry's SQLite side, as carlos: in the agent (/opt/carlos/carlos agent, carlos-edge.service) and in the registry child (carlos __registry-child, which a root pass forks as the service user); the remote arm, unchanged: rev3=J1,J4's route-and-input body, which also writes route_aliases.explicit from RouteAlias.Explicit on insert and in the conflict clause (explicit=excluded.explicit), the DNS and certificate stamps still preserved; self-serve moves 3 Task 1 file:Exec@183,registry:Account@497,registry:Alias@480,registry:App@508,registry:CertExpiresAt@544,registry:CreatedAt@561,registry:DNSCheckedAt@528,registry:DNSStatus@515,registry:Explicit@574,registry:Host@489,registry:remote@57,registry:remote@87,registry:sql@183,whole:RouteAlias@143,whole:RouteAlias@146,whole:remoteClient@59,whole:remoteClient@89 820523f13ffc70b1704122ab9a386a7b962edbd581e172ccd52dc7c87ff4d4ff
+@legacy 1409145169f03f11766765601208065ab115929f8f773b23512c9465d6f6f8e3 internal/registry/registry.go Registry.migrate legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the registry's SQLite side, as carlos: in the agent (/opt/carlos/carlos agent, carlos-edge.service) and in the registry child (carlos __registry-child, which a root pass forks as the service user), on every registry open; a box CLI verb that opens the registry directly runs it as whoever runs the verb: self-serve moves 1's body with route_aliases.explicit (INTEGER NOT NULL DEFAULT 0) in the create statement and one idempotent ALTER for a table created before it (the duplicate-column rule); self-serve moves 3 Task 1 file:Exec@3622,file:Exec@3946,file:Exec@4638,file:Exec@51,file:Exec@5111,file:Exec@5971,file:Exec@6250,file:Exec@7113,file:Exec@7289,registry:sql@3622,registry:sql@3946,registry:sql@4638,registry:sql@51,registry:sql@5111,registry:sql@5971,registry:sql@6250,registry:sql@7113,registry:sql@7289 76673b36d4f61499f9649f3c0cf195ae582b6d21bea50d26dce147d59fb9c990
+internal/registry/registry.go Registry.migrate legacy accepted-legacy accepted-legacy;rev3=PROPOSED-self-serve-moves-3;basis=the registry's SQLite side, as carlos: in the agent (/opt/carlos/carlos agent, carlos-edge.service) and in the registry child (carlos __registry-child, which a root pass forks as the service user), on every registry open; a box CLI verb that opens the registry directly runs it as whoever runs the verb: self-serve moves 1's body with route_aliases.explicit (INTEGER NOT NULL DEFAULT 0) in the create statement and one idempotent ALTER for a table created before it (the duplicate-column rule); self-serve moves 3 Task 1 file:Exec@3622,file:Exec@3946,file:Exec@4638,file:Exec@51,file:Exec@5111,file:Exec@5971,file:Exec@6250,file:Exec@7113,file:Exec@7289,registry:sql@3622,registry:sql@3946,registry:sql@4638,registry:sql@51,registry:sql@5111,registry:sql@5971,registry:sql@6250,registry:sql@7113,registry:sql@7289 76673b36d4f61499f9649f3c0cf195ae582b6d21bea50d26dce147d59fb9c990
+internal/host/agent.go runAgent authority B,C,D,F rev3=PROPOSED-self-serve-moves-3;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): builds the box's lifetime judge once (newBoxJudge, nil with host claims on) and hands it to the seed placement, the moves and runs workers and every loop it starts, and derives the lifetimes switch from it (lifetimes := boxJudge != nil); self-serve moves 3 Task 1 also hands the edges file loop the armed worker's offline view (armedOfflineView); read sites unchanged, offsets moved; B,C,D,F authority retained file:Exec@14221,file:Open@1150,registry:App@10484,whole:Route@10476,whole:Route@23578 e07db82f60d7aca723719b544a3223e9d33f09100f27c6094ecb72a28ee3f218
+internal/host/edgesfile.go edgesFileWriteClaims authority F,J1 rev3=PROPOSED-self-serve-moves-3;basis=root-route;kept=F;scope=protected-decision-only; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): self-serve moves 1's body, whose served alias loop now decides by edgesfile.AliasPublishable over the served rows (the same rule, named once) and which, with the lifetimes switch on and a holding source, publishes the held rows as offline_moves holding entries (holdingEntries), separate from hosts, so no reader counts a held row as served; a report the edge answers holding pages from, not a decision; no record, /data, release or claim read added; F,J1 authority retained registry:Account@11974,registry:Alias@5776,registry:Alias@5811,registry:Alias@6234,registry:Alias@6391,registry:Alias@6461,registry:Alias@6644,registry:Alias@7311,registry:Alias@9666,registry:App@11986,registry:DNSStatus@5909,registry:DNSStatus@6400,registry:Host@11404,registry:Host@3208,registry:Host@5865,registry:Host@6859,registry:Host@7295,registry:Host@7468,registry:Host@9713,registry:Host@9760,registry:Host@9810,registry:Host@9845,registry:Host@9894,registry:Incarnation@3168,registry:Platform@2947,registry:Provisioned@2961,registry:Replica@2985,registry:ServingSeq@3183,registry:Status@914,whole:Route@12277,whole:Route@3462,whole:Route@360,whole:Route@388,whole:Route@3984,whole:RouteAlias@12292,whole:RouteAlias@5761,whole:RouteAlias@6622,whole:RouteAlias@9619,whole:RoutePath@12285,whole:RoutePath@3999,whole:WildcardDomain@9799 bf1537b7f8c48978f8810257ebe099157d3a2264e43fa5cfc2c33fcd50b4c6a6
+internal/host/edgesfile.go holdingEntries authority F,J1 rev3=PROPOSED-self-serve-moves-3;basis=root-route;kept=F;scope=protected-decision-only; the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): new, the writer's holding entries: reads the registry rows edgesFileWriteClaims listed (Status, HoldOp, Host, Platform, Provisioned and the lifetime pair), their path prefixes (the owner projection, servedOwners) and their explicit alias rows through edgesfile.AliasPublishable, and the offline view's move for each held host; a held row whose hold op is the move id the view knows publishes one entry; a report, not a decision; no file, record, release or claim read registry:Alias@744,registry:Alias@799,registry:Explicit@663,registry:HoldOp@307,registry:HoldOp@404,registry:Host@1242,registry:Host@1279,registry:Host@375,registry:Host@438,registry:Host@467,registry:Host@646,registry:Host@766,registry:Host@790,registry:Host@886,registry:Host@931,registry:Host@992,registry:Incarnation@1070,registry:Platform@1010,registry:Provisioned@1024,registry:ServingSeq@1097,registry:Status@294,whole:Route@263,whole:Route@544,whole:Route@858,whole:RouteAlias@617,whole:RouteAlias@724,whole:RoutePath@558 da790578cea389cb43b649dea6b374236bed972f94d382ac85837de512625b2c
+@fields A,Account,Accounts,AddedAt,Addr,Addrs,Alias,Aliases,Alive,AllowAbsent,App,Archives,Args,At,Attempts,B,Backing,Bucket,BuildState,Callback,CallbackError,CallbackFailed,Canary,Caps,CertExpiresAt,Changed,Channel,CheckedAt,ClaimIncarnation,ClaimOwner,ClaimOwnership,ClaimRowID,ClaimStamp,Class,Contents,CreatedAt,DBPath,DNSCheckedAt,DNSStatus,Deadline,Demand,Digest,DoneBefore,Due,DueAt,Env,EnvBefore,Environment,Error,ErrorKind,ExcludeHosts,ExpectGood,ExpectOp,ExpectPair,Expected,Explicit,F,Failed,FenceGen,Fleet,FleetBox,FleetBoxes,Fleets,From,FromDB,FromOp,FromSock,Gate,GoodDemand,GoodDigest,GoodVersion,Hash,Health,Held,Hibernate,HoldOp,Host,Hosts,ID,Id,IdValue,Idle,IdleSeconds,ImportGate,Incarnation,Journal,Keep,Kind,Label,LastAt,LastError,LastMs,LastStatus,Limit,MaxRun,MaxRunSeconds,Method,Ms,Name,Next,NextAt,NextDue,NextGood,NextValue,Now,Number,Number64,Observed,ObservedValue,Old,On,Op,Original,Owner,Ownership,P,Pair,Path,Paths,Platform,Pool,Prefix,Prev,PrevVersion,Provisioned,RecordKey,Region,Registration,Replica,Replicas,Replication,Retirement,Route,Routes,Row,Rows,Seed,SeedID,SeedSource,ServingSeq,ServingVersion,Slug,Socket,Spec,Sqid,State,Status,StatusNamed,Steering,StringMap,T,TValue,Tags,Target,Targets,Text,Timer,Timers,Times,To,ToDB,ToOp,ToSock,TokenHash,Tunnel,Unit,UpdateSettings,Ver,Version,W,WakeIncarnation,Wildcards,closed,conn,connect,ctx,d,dec,done,enc,full,input,mu,output,path,remote,sql,wait
+@coverage internal/domains 0cf7748c97e4caa7d0daa73e251fdd2f2f03e425321599bf046292b0a86ebde8
+@coverage internal/edge 571f37acfaffe9691e56807e111133ffd615e5e255786c190b37d21a58af9f24
+@coverage internal/edgesfile ee82e43c7d6363297f63abcde9c3e828a53d377573649f0d26a9853056aad4be
+@coverage internal/host 2aae5c9642d02b3554d1c45208e565c592d82b35149b2487b4afb2a864011eb5
+@coverage internal/registry ae19eaec5c4dde2c400fcb2fc6afd23c6b129e77306d6916fe55b2091b0fb5ed
diff --git a/internal/registry/alias_explicit_test.go b/internal/registry/alias_explicit_test.go
new file mode 100644
index 000000000..17cf0fcb1
--- /dev/null
+++ b/internal/registry/alias_explicit_test.go
@@ -0,0 +1,135 @@
+package registry
+
+import (
+ "database/sql"
+ "path/filepath"
+ "testing"
+)
+
+// route_aliases.explicit (self-serve moves plan 3 Task 1): an alias row says
+// whether it is backed by an exact domains claim on its own name. Upsert
+// writes it (a repoint rewrites it), ListAliases reads it back, and the
+// fingerprint sees it, so a provenance change alone reloads the edge and
+// republishes the edges file.
+func TestAliasExplicitRoundTrips(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.UpsertAlias(RouteAlias{Alias: "shop.example.com", Host: "shop.bab.example.net", Account: "bab", App: "shop", Explicit: true}); err != nil {
+ t.Fatal(err)
+ }
+ got := aliasByName(t, reg, "shop.example.com")
+ if !got.Explicit {
+ t.Fatalf("explicit alias read back as %+v", got)
+ }
+ // The conflict clause rewrites provenance, both ways.
+ if err := reg.UpsertAlias(RouteAlias{Alias: "shop.example.com", Host: "shop.bab.example.net", Account: "bab", App: "shop"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := aliasByName(t, reg, "shop.example.com"); got.Explicit {
+ t.Fatalf("a derived rewrite kept the explicit mark: %+v", got)
+ }
+ if err := reg.UpsertAlias(RouteAlias{Alias: "shop.example.com", Host: "shop.bab.example.net", Account: "bab", App: "shop", Explicit: true}); err != nil {
+ t.Fatal(err)
+ }
+ if got := aliasByName(t, reg, "shop.example.com"); !got.Explicit {
+ t.Fatalf("a claim-backed rewrite did not set the explicit mark: %+v", got)
+ }
+}
+
+// A registry whose route_aliases table predates the column gains it on open,
+// with every legacy row read as not explicit until the reconciler rewrites it.
+func TestAliasExplicitMigratesALegacyTable(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`CREATE TABLE route_aliases (
+ alias TEXT PRIMARY KEY,
+ host TEXT NOT NULL,
+ account TEXT NOT NULL DEFAULT '',
+ app TEXT NOT NULL DEFAULT '',
+ dns_status TEXT NOT NULL DEFAULT '',
+ dns_checked_at INTEGER NOT NULL DEFAULT 0,
+ cert_expires_at INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL DEFAULT 0
+);
+INSERT INTO route_aliases (alias, host, account, app, dns_status) VALUES ('shop.example.com', 'shop.bab.example.net', 'bab', 'shop', 'pointing');`); err != nil {
+ t.Fatal(err)
+ }
+ db.Close()
+ reg, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ got := aliasByName(t, reg, "shop.example.com")
+ if got.Explicit || got.DNSStatus != "pointing" || got.Host != "shop.bab.example.net" {
+ t.Fatalf("legacy row after migration = %+v", got)
+ }
+ if err := reg.UpsertAlias(RouteAlias{Alias: "shop.example.com", Host: "shop.bab.example.net", Account: "bab", App: "shop", Explicit: true}); err != nil {
+ t.Fatal(err)
+ }
+ if got := aliasByName(t, reg, "shop.example.com"); !got.Explicit || got.DNSStatus != "pointing" {
+ t.Fatalf("upgraded legacy row = %+v, want explicit with its stamp kept", got)
+ }
+}
+
+func TestAliasFingerprintIncludesExplicit(t *testing.T) {
+ reg := openTestRegistry(t)
+ a := RouteAlias{Alias: "shop.example.com", Host: "shop.bab.example.net", Account: "bab", App: "shop"}
+ if err := reg.UpsertAlias(a); err != nil {
+ t.Fatal(err)
+ }
+ derived, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ a.Explicit = true
+ if err := reg.UpsertAlias(a); err != nil {
+ t.Fatal(err)
+ }
+ explicit, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if explicit == derived {
+ t.Fatal("Fingerprint blind to an alias's provenance")
+ }
+ a.Explicit = false
+ if err := reg.UpsertAlias(a); err != nil {
+ t.Fatal(err)
+ }
+ back, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if back != derived {
+ t.Fatalf("Fingerprint after explicit and back = %s, want %s", back, derived)
+ }
+}
+
+// The remote registry (the service-user child) carries the mark both ways.
+func TestAliasExplicitThroughTheRemoteRegistry(t *testing.T) {
+ r, _ := remoteTestRegistry(t)
+ if err := r.UpsertAlias(RouteAlias{Alias: "shop.example.com", Host: "shop.bab.example.net", Explicit: true}); err != nil {
+ t.Fatal(err)
+ }
+ if got := aliasByName(t, r, "shop.example.com"); !got.Explicit {
+ t.Fatalf("remote alias = %+v, want explicit", got)
+ }
+}
+
+func aliasByName(t *testing.T, reg *Registry, name string) RouteAlias {
+ t.Helper()
+ aliases, err := reg.ListAliases()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, a := range aliases {
+ if a.Alias == name {
+ return a
+ }
+ }
+ t.Fatalf("alias %s not listed: %+v", name, aliases)
+ return RouteAlias{}
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 77e476ba7..0dc5bf761 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -642,10 +642,17 @@ CREATE TABLE IF NOT EXISTS route_aliases (
dns_status TEXT NOT NULL DEFAULT '',
dns_checked_at INTEGER NOT NULL DEFAULT 0,
cert_expires_at INTEGER NOT NULL DEFAULT 0,
- created_at INTEGER NOT NULL DEFAULT 0
+ created_at INTEGER NOT NULL DEFAULT 0,
+ explicit INTEGER NOT NULL DEFAULT 0
);`); err != nil {
return err
}
+ // explicit (self-serve moves plan 3 Task 1) is additive over a table
+ // created before it: every legacy row reads 0, not explicit, until the
+ // reconciler's next pass rewrites the claim-backed ones.
+ if _, err := r.sql.Exec(`ALTER TABLE route_aliases ADD COLUMN explicit INTEGER NOT NULL DEFAULT 0`); err != nil && !strings.Contains(err.Error(), "duplicate column") {
+ return err
+ }
// wildcard_domains is a derived table (a later reconcile pass owns every
// row, materializing domains.Claim records whose Wildcard capability is
// set into it) — a plain CREATE TABLE IF NOT EXISTS, same shape as
@@ -2843,6 +2850,13 @@ WHERE kind IN ('instance','sidecar') AND next_due > 0 AND next_due <= ? ORDER BY
// the identically-named columns on routes, and are deliberately excluded
// from Fingerprint (see there) so a stamping pass alone never forces an edge
// reload.
+//
+// Explicit is the row's provenance (self-serve moves plan 3 Task 1): true
+// when the row is backed by an exact domains claim on its own name, false
+// for a name an instance catch-all derives and for a wildcard claim's
+// synthetic "*.<host>" row. Only explicit rows are listed in a held row's
+// holding entry. It is part of Fingerprint, and omitted from JSON when
+// false so a status snapshot of derived rows is the bytes it always was.
type RouteAlias struct {
Alias string
Host string
@@ -2852,6 +2866,7 @@ type RouteAlias struct {
DNSCheckedAt int64
CertExpiresAt int64
CreatedAt int64
+ Explicit bool `json:",omitempty"`
}
// ListAliases returns every route alias, ordered by alias.
@@ -2862,7 +2877,7 @@ func (r *Registry) ListAliases() ([]RouteAlias, error) {
}
rows, err := r.sql.Query(`SELECT alias, host, account, app, dns_status,
-dns_checked_at, cert_expires_at, created_at FROM route_aliases ORDER BY alias`)
+dns_checked_at, cert_expires_at, created_at, explicit FROM route_aliases ORDER BY alias`)
if err != nil {
return nil, err
}
@@ -2871,7 +2886,7 @@ dns_checked_at, cert_expires_at, created_at FROM route_aliases ORDER BY alias`)
for rows.Next() {
var a RouteAlias
if err := rows.Scan(&a.Alias, &a.Host, &a.Account, &a.App, &a.DNSStatus,
- &a.DNSCheckedAt, &a.CertExpiresAt, &a.CreatedAt); err != nil {
+ &a.DNSCheckedAt, &a.CertExpiresAt, &a.CreatedAt, &a.Explicit); err != nil {
return nil, err
}
out = append(out, a)
@@ -2880,7 +2895,8 @@ dns_checked_at, cert_expires_at, created_at FROM route_aliases ORDER BY alias`)
}
// UpsertAlias inserts or updates an alias row by its hostname. Only host,
-// account and app are overwritten on conflict — the DNS/cert stamps and
+// account, app and explicit (the row's provenance, which a reconcile pass
+// rewrites both ways) are overwritten on conflict — the DNS/cert stamps and
// created_at are reconcile-pass- and box-stamp-owned state that a repoint
// must not reset out from under the passes that maintain them (the same
// preserved-column shape Upsert uses for routes, applied here by simply
@@ -2893,10 +2909,10 @@ func (r *Registry) UpsertAlias(a RouteAlias) error {
}
_, err := r.sql.Exec(`
-INSERT INTO route_aliases (alias, host, account, app, dns_status, dns_checked_at, cert_expires_at, created_at)
-VALUES (?, ?, ?, ?, ?, ?, ?, ?)
-ON CONFLICT(alias) DO UPDATE SET host=excluded.host, account=excluded.account, app=excluded.app`,
- a.Alias, a.Host, a.Account, a.App, a.DNSStatus, a.DNSCheckedAt, a.CertExpiresAt, a.CreatedAt)
+INSERT INTO route_aliases (alias, host, account, app, dns_status, dns_checked_at, cert_expires_at, created_at, explicit)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ON CONFLICT(alias) DO UPDATE SET host=excluded.host, account=excluded.account, app=excluded.app, explicit=excluded.explicit`,
+ a.Alias, a.Host, a.Account, a.App, a.DNSStatus, a.DNSCheckedAt, a.CertExpiresAt, a.CreatedAt, a.Explicit)
return err
}
@@ -3284,23 +3300,31 @@ FROM fleet_boxes ORDER BY fleet, label`)
}
// route_aliases: only alias/host/account/app decide which hostnames the
- // edge routes and where. dns_status/dns_checked_at/cert_expires_at are
+ // edge routes and where, and explicit which of them a held row's holding
+ // entry lists (folded in only when set, so a registry with no explicit
+ // row hashes exactly as it did before the column existed).
+ // dns_status/dns_checked_at/cert_expires_at are
// deliberately excluded here for the same reason the routes SELECT above
// omits its own dns_status/cert_expires_at columns — a box-side pass
// stamps them on a 5-minute cadence, and hashing a value that moves on
// that cadence would force a pointless fleet-wide edge reload every pass.
fmt.Fprint(h, "\x1droute_aliases\x1e")
- arows, err := db.Query(`SELECT alias, host, account, app FROM route_aliases ORDER BY alias`)
+ arows, err := db.Query(`SELECT alias, host, account, app, explicit FROM route_aliases ORDER BY alias`)
if err != nil {
return "", err
}
defer arows.Close()
for arows.Next() {
var alias, host, account, app string
- if err := arows.Scan(&alias, &host, &account, &app); err != nil {
+ var explicit bool
+ if err := arows.Scan(&alias, &host, &account, &app, &explicit); err != nil {
return "", err
}
- fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1e", alias, host, account, app)
+ if explicit {
+ fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1fexplicit\x1e", alias, host, account, app)
+ } else {
+ fmt.Fprintf(h, "%s\x1f%s\x1f%s\x1f%s\x1e", alias, host, account, app)
+ }
}
if err := arows.Err(); err != nil {
return "", err