provision: record grows a route-shape vocabulary (additive)
Kind/Addr/Unit/Hibernate/Environment/StatusNamed, every one omitempty with a zero value that reproduces today's mint. Hibernate is a pointer because absent and false are different facts: a plain bool would read every record already in a production bucket as false and un-hibernate the fleet. Design: docs/superpowers/specs/2026-08-10-route-records-design.md §3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+268
−0
internal/provision/provision.go+124 −0internal/provision/provision_test.go+144 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 6b149476f..b622b55bb 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -11,6 +11,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "net" |
| "os" |
| "path/filepath" |
| "regexp" |
| @@ -18,6 +19,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -37,6 +39,37 @@ type Record struct { |
| Channel string `json:"channel"` |
| CreatedAt time.Time `json:"created_at"` |
| CreatedBy string `json:"created_by,omitempty"` |
| + |
| + // --- shape fields (2026-08-10 route-records design §3) -------------- |
| + // Every one is omitempty with a zero value that reproduces the mint |
| + // reconcile hardcoded before they existed: a record written by an older |
| + // console still materializes a byte-identical route. |
| + |
| + // Kind is the route class: "instance" | "service" | "static". Empty |
| + // means "instance", which is what reconcile minted unconditionally |
| + // before this field existed. |
| + Kind string `json:"kind,omitempty"` |
| + // Addr is a TCP upstream as host:port, for whole-app tenants whose |
| + // process the platform does not spawn (kass, keymail, titogo, the |
| + // consoles). Set means the route proxies to it and derives no socket. |
| + Addr string `json:"addr,omitempty"` |
| + // Unit is the systemd unit that owns this route's process, so adoption |
| + // can cycle it (console.service — issue #110). See registry.Route.Unit |
| + // for why a typo here is a route whose adoption silently restarts |
| + // nothing; ValidateShape is the door that refuses one. |
| + Unit string `json:"unit,omitempty"` |
| + // Hibernate is a POINTER because absent and false are different facts: |
| + // every record already in a production bucket has no such key, and a |
| + // plain bool would read those as false and un-hibernate the fleet on |
| + // the first pass after deploy. nil = HibernateOrDefault's derivation. |
| + Hibernate *bool `json:"hibernate,omitempty"` |
| + // Environment is the named config bundle this route binds to ("" = the |
| + // app's default bundle alone). The second member-facing axis of |
| + // `carlos route`, and owned by this record for the same reason Channel |
| + // is (design §5). |
| + Environment string `json:"environment,omitempty"` |
| + // StatusNamed opts this app into being named on the public status page. |
| + StatusNamed bool `json:"status_named,omitempty"` |
| } |
| // Manifest is the wire shape at apps/<sqid>/<app>/instances.json — the app's |
| @@ -140,6 +173,97 @@ func ValidHostShape(host string) bool { |
| return true |
| } |
| +// KindOrDefault resolves the record's route class. "" means "instance" — |
| +// the class reconcile minted unconditionally before Kind existed, and so |
| +// the only value that keeps a legacy record materializing unchanged. |
| +func (r Record) KindOrDefault() string { |
| + if r.Kind == "" { |
| + return "instance" |
| + } |
| + return r.Kind |
| +} |
| + |
| +// DerivesPaths reports whether this record's route builds filesystem paths |
| +// out of its host — the <host>.sock and <host>.db segments. It is the hinge |
| +// two separate rules turn on: which host validator applies (ValidRecordHost: |
| +// a host that becomes a path segment stays suffix-pinned, one that does not |
| +// needs only a valid shape), and what hibernation defaults to (only a route |
| +// with a socket has a process for the activator to wake). |
| +func (r Record) DerivesPaths() bool { |
| + return r.KindOrDefault() == "instance" && r.Addr == "" |
| +} |
| + |
| +// HibernateOrDefault resolves the tri-state. An explicit value wins. Absent |
| +// derives: a socket-backed instance hibernates (the activator model, and |
| +// what reconcile minted unconditionally before this field existed, so every |
| +// legacy record is unchanged), and everything else does not — a static |
| +// route has no process to wake, and an addr-backed tenant is always on. |
| +func (r Record) HibernateOrDefault() bool { |
| + if r.Hibernate != nil { |
| + return *r.Hibernate |
| + } |
| + return r.DerivesPaths() |
| +} |
| + |
| +// ValidKind pins the record's route-class vocabulary to the set `carlos |
| +// add` documents. "status" is deliberately absent: the fleet status page's |
| +// route is the platform's own, not an app's, and nothing should be able to |
| +// mint one from an app-scoped record. |
| +func ValidKind(kind string) bool { |
| + switch kind { |
| + case "", "instance", "service", "static": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// ValidateShape checks the §3 fields at the door, before a record is written |
| +// or materialized. The rails live at the door, exactly as they do for |
| +// registry.SetCaps and SetChannel — Upsert validates no column at all, so a |
| +// value that reaches the row unvalidated is discovered months later as an |
| +// adopt pass that restarted nothing (the `-backing unti` incident, cli.go). |
| +func ValidateShape(rec Record) error { |
| + if !ValidKind(rec.Kind) { |
| + return fmt.Errorf("kind %q: want instance, service or static (empty means instance)", rec.Kind) |
| + } |
| + if rec.Addr != "" { |
| + h, p, err := net.SplitHostPort(rec.Addr) |
| + if err != nil || h == "" || p == "" { |
| + return fmt.Errorf("addr %q: want a TCP upstream as host:port (e.g. 127.0.0.1:8090)", rec.Addr) |
| + } |
| + } |
| + if rec.Unit != "" && !registry.ValidUnitName(rec.Unit) { |
| + return fmt.Errorf("unit %q: want a systemd service unit name (e.g. console.service)", rec.Unit) |
| + } |
| + if rec.KindOrDefault() == "static" { |
| + if rec.Addr != "" { |
| + return errors.New("kind static with addr: a static route serves a file tree and proxies to nothing — drop one of the two") |
| + } |
| + if rec.Unit != "" { |
| + return errors.New("kind static with unit: a static route has no process for a unit to own — drop one of the two") |
| + } |
| + if rec.Hibernate != nil && *rec.Hibernate { |
| + return errors.New("kind static with hibernate: a static route has no process to wake, so arming the activator for it would wedge the host — drop hibernate") |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// ValidRecordHost applies the host rail this record actually needs (design |
| +// §3.2). ValidHost is path-traversal defence: it is what keeps a hostile |
| +// host from reaching the <host>.sock and <host>.db path segments as anything |
| +// but an opaque, safe component, and it stays exactly where that danger is. |
| +// A record that derives no filesystem path has no such exposure — its host |
| +// reaches a bucket key and the edge's routing table only — so it needs the |
| +// shape check alone, which is what lets an apex (carloku.com) or a console's |
| +// own hostname be expressed at all. |
| +func ValidRecordHost(rec Record, domain string) bool { |
| + if rec.DerivesPaths() { |
| + return ValidHost(rec.Host, domain) |
| + } |
| + return ValidHostShape(rec.Host) |
| +} |
| + |
| // regionPoolRe is the shared charset for a region or pool code: lowercase |
| // a-z, 0-9, hyphen, up to 16 characters. Empty is deliberately valid — the |
| // unconstrained region and the shared (default) pool are both "". |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index a42cbc7c4..c96287e1e 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -12,6 +12,68 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// A schema-1 record exactly as the flagship and tito buckets hold it today: |
| +// none of the shape fields exist. Every one must default to what reconcile |
| +// hardcoded before they were introduced — a non-nil Hibernate here, or a |
| +// Kind that does not fall back to "instance", rewrites the fleet on the |
| +// first pass after deploy. This is the additive wire-shape rule stated as a |
| +// test; do not relax it. |
| +func TestRecordWireShapeIsAdditive(t *testing.T) { |
| + const legacy = `{"schema":1,"host":"acme.go.tito.io","slug":"acme","app":"tito",` + |
| + `"account":"bab","pool":"tito","region":"ie","channel":"stable",` + |
| + `"created_at":"2026-08-04T09:00:00Z"}` |
| + var rec Record |
| + if err := json.Unmarshal([]byte(legacy), &rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Kind != "" { |
| + t.Errorf("Kind = %q, want empty", rec.Kind) |
| + } |
| + if rec.Addr != "" { |
| + t.Errorf("Addr = %q, want empty", rec.Addr) |
| + } |
| + if rec.Unit != "" { |
| + t.Errorf("Unit = %q, want empty", rec.Unit) |
| + } |
| + if rec.Environment != "" { |
| + t.Errorf("Environment = %q, want empty", rec.Environment) |
| + } |
| + if rec.StatusNamed { |
| + t.Error("StatusNamed = true, want false") |
| + } |
| + if rec.Hibernate != nil { |
| + t.Errorf("Hibernate = %v, want nil — absent and false are different facts here", rec.Hibernate) |
| + } |
| + if got := rec.KindOrDefault(); got != "instance" { |
| + t.Errorf("KindOrDefault() = %q, want instance", got) |
| + } |
| + if !rec.DerivesPaths() { |
| + t.Error("DerivesPaths() = false, want true — a legacy record is a socket-backed instance") |
| + } |
| + if !rec.HibernateOrDefault() { |
| + t.Error("HibernateOrDefault() = false, want true — reconcile mints Hibernate:true for these today") |
| + } |
| +} |
| + |
| +// The console rewrites records (plan 2's PUT). A rewrite must not sprout |
| +// keys the reader before it did not know, so every added field is |
| +// omitempty and a record carrying none of them marshals to exactly what it |
| +// marshalled to before. |
| +func TestRecordMarshalOmitsAbsentShapeFields(t *testing.T) { |
| + body, err := json.Marshal(Record{ |
| + Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: "bab", Pool: "tito", Region: "ie", Channel: "stable", |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named"} { |
| + if strings.Contains(string(body), `"`+key+`"`) { |
| + t.Errorf("marshalled record carries %q when it was never set: %s", key, body) |
| + } |
| + } |
| +} |
| + |
| func TestValidHost(t *testing.T) { |
| cases := []struct { |
| host, domain string |
| @@ -375,3 +437,85 @@ func TestPutManifestIfMatch(t *testing.T) { |
| t.Fatalf("manifest after stale write = %+v %v, want the guarded update's beta", final, err) |
| } |
| } |
| + |
| +func TestValidKind(t *testing.T) { |
| + for _, k := range []string{"", "instance", "service", "static"} { |
| + if !ValidKind(k) { |
| + t.Errorf("ValidKind(%q) = false, want true", k) |
| + } |
| + } |
| + // "status" is the platform's own status-page class, not an app's. |
| + for _, k := range []string{"status", "Instance", "unti", "instance "} { |
| + if ValidKind(k) { |
| + t.Errorf("ValidKind(%q) = true, want false", k) |
| + } |
| + } |
| +} |
| + |
| +func TestValidateShape(t *testing.T) { |
| + yes := true |
| + base := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", |
| + Account: "bab", Channel: "stable"} |
| + with := func(f func(*Record)) Record { |
| + r := base |
| + f(&r) |
| + return r |
| + } |
| + ok := []struct { |
| + name string |
| + rec Record |
| + }{ |
| + {"legacy instance", base}, |
| + {"addr tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090" })}, |
| + {"unit-owned tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090"; r.Unit = "console.service" })}, |
| + {"static", with(func(r *Record) { r.Kind = "static" })}, |
| + {"explicitly non-hibernating instance", with(func(r *Record) { r.Hibernate = new(bool) })}, |
| + } |
| + for _, c := range ok { |
| + if err := ValidateShape(c.rec); err != nil { |
| + t.Errorf("%s: ValidateShape = %v, want nil", c.name, err) |
| + } |
| + } |
| + bad := []struct { |
| + name string |
| + rec Record |
| + }{ |
| + {"bogus kind", with(func(r *Record) { r.Kind = "unti" })}, |
| + {"status kind", with(func(r *Record) { r.Kind = "status" })}, |
| + {"addr without port", with(func(r *Record) { r.Addr = "127.0.0.1" })}, |
| + {"unit typo", with(func(r *Record) { r.Unit = "console" })}, |
| + {"static with addr", with(func(r *Record) { r.Kind = "static"; r.Addr = "127.0.0.1:8090" })}, |
| + {"static with unit", with(func(r *Record) { r.Kind = "static"; r.Unit = "console.service" })}, |
| + {"static hibernating", with(func(r *Record) { r.Kind = "static"; r.Hibernate = &yes })}, |
| + } |
| + for _, c := range bad { |
| + if err := ValidateShape(c.rec); err == nil { |
| + t.Errorf("%s: ValidateShape = nil, want a refusal", c.name) |
| + } |
| + } |
| +} |
| + |
| +// Design §3.2: the suffix pin stays exactly where the traversal danger is, |
| +// and nowhere else. |
| +func TestValidRecordHostFollowsPathDerivation(t *testing.T) { |
| + socketBacked := Record{Host: "acme.go.tito.io"} |
| + if !ValidRecordHost(socketBacked, "go.tito.io") { |
| + t.Error("a socket-backed instance under its own domain was refused") |
| + } |
| + offDomain := Record{Host: "carloku.com"} |
| + if ValidRecordHost(offDomain, "go.tito.io") { |
| + t.Error("a socket-backed instance escaped its domain suffix — that is the traversal rail") |
| + } |
| + static := Record{Host: "carloku.com", Kind: "static"} |
| + if !ValidRecordHost(static, "go.tito.io") { |
| + t.Error("a static apex was refused; it derives no path and needs shape validation only") |
| + } |
| + addrBacked := Record{Host: "console.platform.tito.io", Addr: "127.0.0.1:8090"} |
| + if !ValidRecordHost(addrBacked, "go.tito.io") { |
| + t.Error("an addr-backed tenant off-domain was refused; it derives no path") |
| + } |
| + hostile := Record{Host: "../../etc/passwd", Kind: "static"} |
| + if ValidRecordHost(hostile, "go.tito.io") { |
| + t.Error("a hostile host passed the shape check") |
| + } |
| +} |