carlos / platform Public

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>
Paul Campbell 7d5e81de90837979de963d46a16ecaeac4018dc8 parent c13fb6b
2 files changed, +268 −0
  • internal/provision/provision.go +124 −0
  • internal/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")
+ }
+}