carlos / platform Public

registry: per-route capability grants — the caps column and its two setters

An app DECLARES `ports`/`host_role` in its manifest; nothing materializes
until an operator has granted it on THIS box. This is where that grant
lives: an additive `caps TEXT NOT NULL DEFAULT ''` column, written only by
SetCaps behind `carlos route -grant/-revoke`, in the one place on the box no
tenant can write.

internal/caps gains the token vocabulary (`host-role`, `port:<n>`), and it is
closed and strict in both directions. ValidGrantToken refuses 22/80/443
absolutely — an operator typo must not be able to hand a tenant the edge's
ports or the box's SSH — refuses anything outside 1..1023, and refuses a
non-canonical port spelling, so one grant has exactly one spelling and the
comma-separated column stays unambiguous. ParseGrants refuses a token the
formatter would never have written rather than silently honouring the subset
that parses: this column has one writer, so anything else in it means a hand
edit, and the honest answer is to refuse to materialize rather than guess.

Upsert PRESERVES a non-empty caps column, exactly as it preserves
environment (2026-08-02 review, IMPORTANT 3). No full-row Upsert caller
knows anything about grants — `carlos add` re-adds, the domains reconciler
re-mints — so a plain excluded.caps would silently revoke an operator's
port:25 on a re-run nobody would connect to the capability vanishing an hour
later on the next converge pass.

Fingerprint deliberately does NOT gain caps: a grant changes unit shape, not
routing, and the edge has no reason to reload for one.

SetDBPath joins it as the converge pass's narrow stamp, so ops.FleetDBs can
learn where a platform-replicated app's databases live from the manifest
rather than from an operator having remembered -db at `carlos add`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell aac7cd038bfe123a93f2140205319c55012128d8 parent dce2905
4 files changed, +719 −10
  • internal/caps/grants.go +214 −0
  • internal/caps/grants_test.go +184 −0
  • internal/registry/registry.go +97 −8
  • internal/registry/registry_test.go +224 −2
diff --git a/internal/caps/grants.go b/internal/caps/grants.go
new file mode 100644
index 000000000..8c5327f66
--- /dev/null
+++ b/internal/caps/grants.go
@@ -0,0 +1,214 @@
+package caps
+
+// Grants are the OPERATOR half of the capability model (spec §2 "Grants",
+// §7): a manifest DECLARES `ports` and `host_role`, and nothing materializes
+// until an operator has granted it ON THIS BOX. The grant lives in the
+// registry's `caps` column — a place no tenant can write — and this file is
+// the whole vocabulary of what that column may say.
+//
+// The token set is closed and tiny:
+//
+// host-role the app's processes may use the box's instance role
+// port:<n> the app may bind privileged port n (1..1023, never 22/80/443)
+//
+// Per route, per box, deliberately (decision 4): `:25` can be held by exactly
+// one process on one machine, and the IMDS gate is that box's firewall. Two
+// boxes running the same app is two grants.
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// HostRoleToken is the grant that hands an app the box's cloud instance
+// role — the one real transfer of authority in the model, and the reason
+// grants are typed by an operator on the box rather than declared by an app
+// in a manifest (spec §7).
+const HostRoleToken = "host-role"
+
+// portTokenPrefix is the only other token shape. Kept as a constant so the
+// parser and the formatter can never drift apart by a character.
+const portTokenPrefix = "port:"
+
+// PortToken is the canonical grant token for a privileged port ("port:25").
+// It is the ONLY producer of that string: ValidGrantToken refuses any port
+// token this function would not have written, so "port:025" and "port:+25"
+// never reach the column and two spellings of one grant can never coexist.
+func PortToken(p int) string { return portTokenPrefix + strconv.Itoa(p) }
+
+// Grants is a parsed `caps` column: which privileged ports this route may
+// bind on this box, and whether it may use the box's instance role.
+type Grants struct {
+ Ports map[int]bool
+ HostRole bool
+}
+
+// Has reports whether g holds tok. The token is not validated — an
+// unparseable token is simply not held, which is the honest answer for
+// "is this granted?".
+func (g Grants) Has(tok string) bool {
+ if tok == HostRoleToken {
+ return g.HostRole
+ }
+ if p, ok := parsePortToken(tok); ok {
+ return g.Ports[p]
+ }
+ return false
+}
+
+// String is the canonical column value: every held grant as a token, sorted
+// lexicographically ("host-role,port:25"), so re-granting a set already
+// granted writes the same bytes and a diff of two registries is meaningful.
+// The zero Grants is "".
+//
+// The sort is over the token STRINGS, not the port numbers, so ports order
+// as "port:25", "port:465", "port:5". That is deliberate: the only property
+// this value needs is that one set has exactly one spelling.
+func (g Grants) String() string {
+ var toks []string
+ if g.HostRole {
+ toks = append(toks, HostRoleToken)
+ }
+ for p, ok := range g.Ports {
+ if ok {
+ toks = append(toks, PortToken(p))
+ }
+ }
+ sort.Strings(toks)
+ return strings.Join(toks, ",")
+}
+
+// ParseGrants reads the registry's `caps` column. Unknown or malformed
+// tokens are an ERROR, not a silent skip: this column is written ONLY by
+// registry.SetCaps behind `carlos route -grant/-revoke`, so anything else in
+// it means the row was edited by hand, and the honest answer is to refuse to
+// materialize the route rather than guess which subset was meant. A
+// duplicate token is refused on the same grounds — String never writes one.
+//
+// "" is the zero Grants with no error: nothing granted, which is every row
+// on every box today and the behaviour byte for byte.
+func ParseGrants(col string) (Grants, error) {
+ var g Grants
+ if col == "" {
+ return g, nil
+ }
+ for _, tok := range strings.Split(col, ",") {
+ if err := ValidGrantToken(tok); err != nil {
+ return Grants{}, fmt.Errorf("caps column %q: %w", col, err)
+ }
+ if g.Has(tok) {
+ return Grants{}, fmt.Errorf("caps column %q: %q appears more than once — this column is written only by `carlos route -grant`, so a duplicate means a hand edit", col, tok)
+ }
+ if tok == HostRoleToken {
+ g.HostRole = true
+ continue
+ }
+ p, _ := parsePortToken(tok) // ValidGrantToken proved the shape
+ if g.Ports == nil {
+ g.Ports = map[int]bool{}
+ }
+ g.Ports[p] = true
+ }
+ return g, nil
+}
+
+// ValidGrantToken is the rail an operator meets at `carlos route -grant`,
+// and the same rail ParseGrants applies to the stored column. It refuses:
+//
+// - an unknown token shape (only "host-role" and "port:<n>" exist);
+// - a non-canonical port spelling ("port:025", "port:+25", "port: 25") —
+// one grant has exactly one spelling;
+// - a port outside 1..1023, because a high port needs no help from the
+// platform and letting the vocabulary cover them would blur what the
+// capability is for (spec §7);
+// - 22, 80 and 443, which are reserved ABSOLUTELY and are not grantable:
+// an operator typo must not be able to hand a tenant the edge's ports or
+// the box's SSH.
+//
+// A token can therefore never contain a comma or a newline, which is what
+// keeps the comma-separated column unambiguous.
+func ValidGrantToken(tok string) error {
+ if tok == "" {
+ return fmt.Errorf("grant token is empty — want %q or %q", HostRoleToken, PortToken(25))
+ }
+ if tok == HostRoleToken {
+ return nil
+ }
+ if !strings.HasPrefix(tok, portTokenPrefix) {
+ return fmt.Errorf("unknown grant token %q — the vocabulary is %q and %q", tok, HostRoleToken, portTokenPrefix+"<n>")
+ }
+ p, ok := parsePortToken(tok)
+ if !ok {
+ return fmt.Errorf("grant token %q: %s must be a decimal port number, canonically spelled (e.g. %q)", tok, portTokenPrefix+"<n>", PortToken(25))
+ }
+ if p < 1 || p > 1023 {
+ return fmt.Errorf("grant token %q: port %d is outside 1..1023 — only privileged ports are a capability; a high port needs no help from the platform", tok, p)
+ }
+ if reservedPort[p] {
+ return fmt.Errorf("grant token %q: port %d is reserved absolutely and is not grantable — it belongs to the edge or to SSH", tok, p)
+ }
+ return nil
+}
+
+// parsePortToken returns the port a "port:<n>" token names, and whether the
+// token is that shape AND spelled the way PortToken would have spelled it.
+// The round-trip check is what refuses "port:025" and "port:+25": strconv
+// accepts both, and accepting them would let one grant have two spellings in
+// one column.
+func parsePortToken(tok string) (int, bool) {
+ rest, ok := strings.CutPrefix(tok, portTokenPrefix)
+ if !ok {
+ return 0, false
+ }
+ p, err := strconv.Atoi(rest)
+ if err != nil || PortToken(p) != tok {
+ return 0, false
+ }
+ return p, true
+}
+
+// Grant returns the canonical column value with tok added. Granting what is
+// already granted is idempotent — same set, same bytes — so a re-run of an
+// operator's command is a success, not a conflict.
+func Grant(col, tok string) (string, error) {
+ if err := ValidGrantToken(tok); err != nil {
+ return "", err
+ }
+ g, err := ParseGrants(col)
+ if err != nil {
+ return "", err
+ }
+ if tok == HostRoleToken {
+ g.HostRole = true
+ } else {
+ p, _ := parsePortToken(tok)
+ if g.Ports == nil {
+ g.Ports = map[int]bool{}
+ }
+ g.Ports[p] = true
+ }
+ return g.String(), nil
+}
+
+// Revoke returns the canonical column value with tok removed. Revoking a
+// grant that was never made is a quiet success: the caller's goal is "not
+// granted", not "I removed something" (the same contract DeleteAccount
+// documents). The token is still validated — an operator who typed a
+// nonsense token deserves to hear so rather than a cheerful no-op.
+func Revoke(col, tok string) (string, error) {
+ if err := ValidGrantToken(tok); err != nil {
+ return "", err
+ }
+ g, err := ParseGrants(col)
+ if err != nil {
+ return "", err
+ }
+ if tok == HostRoleToken {
+ g.HostRole = false
+ } else if p, ok := parsePortToken(tok); ok {
+ delete(g.Ports, p)
+ }
+ return g.String(), nil
+}
diff --git a/internal/caps/grants_test.go b/internal/caps/grants_test.go
new file mode 100644
index 000000000..a86c81931
--- /dev/null
+++ b/internal/caps/grants_test.go
@@ -0,0 +1,184 @@
+package caps
+
+// The operator half of the model: what may be written into the registry's
+// `caps` column, what a hand-edited column does, and why one granted set has
+// exactly one spelling.
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestValidGrantTokenRefusals pins the closed vocabulary an operator meets at
+// `carlos route -grant`. The three reserved ports are the load-bearing rows:
+// they are refused ABSOLUTELY, so an operator typo can never hand a tenant
+// the edge's ports or the box's SSH (spec §7).
+func TestValidGrantTokenRefusals(t *testing.T) {
+ for _, tok := range []string{HostRoleToken, "port:25", "port:465", "port:1", "port:1023"} {
+ if err := ValidGrantToken(tok); err != nil {
+ t.Fatalf("ValidGrantToken(%q) = %v, want accepted", tok, err)
+ }
+ }
+ cases := []struct {
+ tok string
+ says string
+ }{
+ {"port:22", "reserved"},
+ {"port:80", "reserved"},
+ {"port:443", "reserved"},
+ {"port:8080", "1..1023"},
+ {"port:0", "1..1023"},
+ {"port:1024", "1..1023"},
+ {"port:99999", "1..1023"},
+ {"port:-1", "1..1023"},
+ {"hostrole", "unknown grant token"},
+ {"host-role:1", "unknown grant token"},
+ {"", "empty"},
+ {"port:", "decimal port number"},
+ {"port:x", "decimal port number"},
+ // One grant, one spelling: strconv would take all three.
+ {"port:025", "canonically spelled"},
+ {"port:+25", "canonically spelled"},
+ {"port: 25", "decimal port number"},
+ // A token can never carry the column's own separator, or a newline.
+ {"port:25,host-role", "decimal port number"},
+ {"host-role\n", "unknown grant token"},
+ }
+ for _, c := range cases {
+ err := ValidGrantToken(c.tok)
+ if err == nil {
+ t.Fatalf("ValidGrantToken(%q) accepted; want a refusal", c.tok)
+ }
+ if !strings.Contains(err.Error(), c.says) {
+ t.Fatalf("ValidGrantToken(%q) = %q, want it to say %q", c.tok, err, c.says)
+ }
+ }
+}
+
+// TestParseGrantsRoundTrip: the column round-trips, and "" — every row on
+// every box today — is the zero Grants with no error, meaning "nothing
+// granted".
+func TestParseGrantsRoundTrip(t *testing.T) {
+ g, err := ParseGrants("")
+ if err != nil || g.HostRole || len(g.Ports) != 0 {
+ t.Fatalf(`ParseGrants("") = (%+v, %v), want the zero Grants and no error`, g, err)
+ }
+ if g.String() != "" {
+ t.Fatalf("zero Grants formats as %q, want %q", g.String(), "")
+ }
+
+ g, err = ParseGrants("host-role,port:25")
+ if err != nil {
+ t.Fatalf("ParseGrants: %v", err)
+ }
+ if !g.HostRole || !g.Ports[25] || len(g.Ports) != 1 {
+ t.Fatalf("ParseGrants = %+v, want host-role and port 25", g)
+ }
+ if got := g.String(); got != "host-role,port:25" {
+ t.Fatalf("String() = %q, want the canonical column value", got)
+ }
+ // A hand-written column in the other order normalises to the same bytes.
+ g, err = ParseGrants("port:25,host-role")
+ if err != nil {
+ t.Fatalf("ParseGrants: %v", err)
+ }
+ if got := g.String(); got != "host-role,port:25" {
+ t.Fatalf("String() = %q, want the canonical order regardless of input order", got)
+ }
+ if !g.Has(HostRoleToken) || !g.Has("port:25") || g.Has("port:465") || g.Has("nonsense") {
+ t.Fatalf("Has is wrong for %+v", g)
+ }
+}
+
+// TestParseGrantsRefusesAHandEditedColumn: this column is written only by
+// registry.SetCaps behind `carlos route -grant`, so anything the formatter
+// would not have written means a human edited the row — and the honest
+// answer is to refuse to materialize, never to silently honour the subset
+// that happens to parse.
+func TestParseGrantsRefusesAHandEditedColumn(t *testing.T) {
+ for _, col := range []string{
+ "port:99999", // out of range
+ "port:22", // reserved, ungrantable
+ "gpu", // not in the vocabulary
+ "host-role,", // empty element
+ ",port:25", // empty element
+ "host-role, port:25", // whitespace is not trimmed: one spelling only
+ "host-role,host-role", // the formatter never writes a duplicate
+ "port:25,port:025", // nor two spellings of one grant
+ } {
+ if g, err := ParseGrants(col); err == nil {
+ t.Fatalf("ParseGrants(%q) = %+v, want a refusal", col, g)
+ }
+ }
+}
+
+// TestGrantRevokeIdempotent: granting what is granted writes the same bytes,
+// and revoking what was never granted is a quiet success — the caller's goal
+// is "not granted", not "I removed something".
+func TestGrantRevokeIdempotent(t *testing.T) {
+ col, err := Grant("", "port:25")
+ if err != nil || col != "port:25" {
+ t.Fatalf(`Grant("", "port:25") = (%q, %v)`, col, err)
+ }
+ again, err := Grant(col, "port:25")
+ if err != nil || again != col {
+ t.Fatalf("re-granting changed the column: %q -> %q (%v)", col, again, err)
+ }
+ col, err = Grant(col, HostRoleToken)
+ if err != nil || col != "host-role,port:25" {
+ t.Fatalf("Grant(host-role) = (%q, %v), want the canonical two-token column", col, err)
+ }
+ // Order of granting does not change the stored bytes.
+ other, err := Grant("host-role", "port:25")
+ if err != nil || other != col {
+ t.Fatalf("granting in the other order = %q, want %q", other, col)
+ }
+
+ col, err = Revoke(col, "port:25")
+ if err != nil || col != "host-role" {
+ t.Fatalf("Revoke(port:25) = (%q, %v), want just host-role left", col, err)
+ }
+ // Absent grant: no-op, no error.
+ col, err = Revoke(col, "port:25")
+ if err != nil || col != "host-role" {
+ t.Fatalf("revoking an absent grant = (%q, %v), want a quiet no-op", col, err)
+ }
+ col, err = Revoke(col, HostRoleToken)
+ if err != nil || col != "" {
+ t.Fatalf("Revoke(host-role) = (%q, %v), want an empty column", col, err)
+ }
+ // Revoking from an empty column is still a quiet success.
+ if col, err = Revoke("", HostRoleToken); err != nil || col != "" {
+ t.Fatalf(`Revoke("", host-role) = (%q, %v), want ("", nil)`, col, err)
+ }
+ // But a nonsense token is refused rather than cheerfully no-oped, on
+ // both verbs: an operator who typed rubbish deserves to hear so.
+ if _, err := Revoke("port:25", "port:22"); err == nil {
+ t.Fatal("Revoke accepted an ungrantable token")
+ }
+ if _, err := Grant("", "gpu"); err == nil {
+ t.Fatal("Grant accepted a token outside the vocabulary")
+ }
+ // A hand-edited column is refused by both, not repaired by them.
+ if _, err := Grant("gpu", "port:25"); err == nil {
+ t.Fatal("Grant repaired a hand-edited column instead of refusing it")
+ }
+ if _, err := Revoke("gpu", "port:25"); err == nil {
+ t.Fatal("Revoke repaired a hand-edited column instead of refusing it")
+ }
+}
+
+// TestPortTokenMatchesTheManifestVocabulary: a declared port and its grant
+// token must agree, or a grant an operator types could never satisfy the
+// declaration it was typed for. ValidCapabilities and ValidGrantToken share
+// the reserved list; this pins that they also share the range.
+func TestPortTokenMatchesTheManifestVocabulary(t *testing.T) {
+ for p := 1; p <= 1023; p++ {
+ declarable := validPorts([]Port{{Port: p}}) == nil
+ grantable := ValidGrantToken(PortToken(p)) == nil
+ if declarable != grantable {
+ t.Fatalf("port %d: declarable=%v grantable=%v — a declaration nobody can grant, or a grant nothing can declare",
+ p, declarable, grantable)
+ }
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index d3431f19e..cefa35757 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -69,6 +69,25 @@ type Route struct {
// rebuilds is not one this verb should be handing an operator.
Environment string
+ // Caps is the OPERATOR-granted capability tokens for this route ON THIS
+ // BOX: comma-separated and canonically sorted, so "port:25,host-role"
+ // normalises to "host-role,port:25" (the vocabulary and the formatter are
+ // internal/caps' Grants — this column's only reader and writer). Default
+ // '' = nothing granted = today's behaviour for every row already on every
+ // box.
+ //
+ // Written ONLY by SetCaps, behind `carlos route -grant/-revoke`. A
+ // manifest DECLARES `ports`/`host_role`; nothing materializes until an
+ // operator has granted it here, on this box — which is the whole point of
+ // the column living in the registry, where no tenant can write (see
+ // docs/superpowers/specs/2026-08-03-app-capabilities-design.md §2, §7).
+ // Upsert preserves a non-empty existing value for the same reason it
+ // preserves Environment; see its doc comment.
+ //
+ // Deliberately NOT in Fingerprint(): grants change unit shape, not
+ // routing, and the edge has no reason to reload for one.
+ Caps string
+
Hibernate bool
Canary bool
Replication string // "" | "stream" | "batched" | "daily"
@@ -179,6 +198,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN cert_expires_at INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE routes ADD COLUMN account TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN environment TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN caps TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -341,10 +361,16 @@ func (r *Registry) Accounts() ([]Account, error) {
return out, rows.Err()
}
+// routeCols is the column list every route read and the one route write
+// share — 28 names, in this exact order. scanRoute's destinations, Upsert's
+// placeholder row and Upsert's argument list all count from here: adding a
+// column means adding it in FOUR places, and a miscount is a silent column
+// shift rather than an error (TestUpsertRoundTripsAllColumns is what catches
+// one).
const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path,
version, prev_version, app, pool, region, channel, hibernate, canary,
replication, next_due, backing, status_named, platform, dns_status,
-dns_checked_at, cert_expires_at, account, environment, created_at`
+dns_checked_at, cert_expires_at, account, environment, caps, created_at`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
@@ -353,7 +379,7 @@ func scanRoute(scan func(...any) error) (Route, error) {
&t.LastError, &t.DBPath, &t.Version, &t.PrevVersion, &t.App, &t.Pool,
&t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing,
&sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account,
- &t.Environment, &t.CreatedAt)
+ &t.Environment, &t.Caps, &t.CreatedAt)
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -413,9 +439,10 @@ func (r *Registry) Delete(host string) error {
return err
}
-// Upsert inserts or updates a route by host. One column is deliberately not
-// a plain overwrite: `environment` keeps the existing row's value when the
-// incoming Route carries the zero value (2026-08-02 review, IMPORTANT 3).
+// Upsert inserts or updates a route by host. Two columns are deliberately not
+// a plain overwrite: `environment` and `caps` keep the existing row's value
+// when the incoming Route carries the zero value (2026-08-02 review,
+// IMPORTANT 3, and the same lesson applied to grants).
// Every full-row Upsert caller — `carlos add` and the domains reconciler's
// two mint paths — builds its Route without knowing anything about
// environment bindings, so a plain excluded.environment silently unbound a
@@ -424,6 +451,14 @@ func (r *Registry) Delete(host string) error {
// explicit non-empty value still wins, and clearing goes through
// SetEnvironment (what `carlos route -environment -` calls), which is the
// only place a clear is ever intended.
+//
+// `caps` is the same shape of bug with a worse blast radius: no full-row
+// Upsert caller knows anything about grants either, so a plain excluded.caps
+// would silently REVOKE an operator's `port:25` the next time `carlos add`
+// re-ran or the domains reconciler re-minted the row — a capability
+// disappearing on a re-add nobody connected to it, hours later, when the
+// converge pass next ran. Revoking has exactly one door, SetCaps, behind
+// `carlos route -revoke`.
func (r *Registry) Upsert(t Route) error {
if t.CreatedAt == 0 {
t.CreatedAt = time.Now().Unix()
@@ -442,7 +477,7 @@ func (r *Registry) Upsert(t Route) error {
}
_, err := r.sql.Exec(`
INSERT INTO routes (`+routeCols+`)
-VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(host) DO UPDATE SET
slug=excluded.slug, kind=excluded.kind, socket=excluded.socket,
addr=excluded.addr, status=excluded.status, last_error=excluded.last_error,
@@ -455,12 +490,13 @@ ON CONFLICT(host) DO UPDATE SET
platform=excluded.platform, dns_status=excluded.dns_status,
dns_checked_at=excluded.dns_checked_at,
cert_expires_at=excluded.cert_expires_at, account=excluded.account,
- environment=CASE WHEN excluded.environment = '' THEN routes.environment ELSE excluded.environment END`,
+ environment=CASE WHEN excluded.environment = '' THEN routes.environment ELSE excluded.environment END,
+ caps=CASE WHEN excluded.caps = '' THEN routes.caps ELSE excluded.caps END`,
t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel,
b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing,
b2i(t.StatusNamed), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt,
- t.CertExpiresAt, t.Account, t.Environment, t.CreatedAt)
+ t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.CreatedAt)
return err
}
@@ -494,6 +530,59 @@ func (r *Registry) SetChannel(host, channel string) error {
return nil
}
+// SetCaps writes a route's operator-granted capability tokens — the ONLY
+// writer of that column, and deliberately the only one: `carlos route
+// -grant/-revoke` is the operator's door to a transfer of box authority, and
+// a second writer (a full-row Upsert, a reconcile pass) would be a way for a
+// grant to appear or disappear without anyone typing it. Callers pass the
+// canonical value caps.Grant/caps.Revoke returned; this setter validates
+// nothing, exactly as SetChannel validates no channel name — the rails live
+// at the door.
+//
+// Same contract and same rationale as SetChannel: a narrow UPDATE (so it
+// cannot clobber a concurrent SetVersions or SetNextDue), and an error when
+// no row matched, because a human types a hostname into this one directly
+// and a silent no-op on a typo would be worse than a refusal.
+func (r *Registry) SetCaps(host, caps string) error {
+ res, err := r.sql.Exec(`UPDATE routes SET caps = ? WHERE host = ?`, caps, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
+// SetDBPath stamps a route's database path narrowly. The converge pass uses
+// it to teach ops.FleetDBs (the box-wide litestream unit's config generator)
+// where a `data.replicate: platform` app's databases actually live, instead
+// of depending on an operator having remembered `-db` at `carlos add` — the
+// manifest names the database FILES, the platform knows the state directory,
+// and this is where the two meet.
+//
+// Errors on an unmatched host, like SetChannel and SetCaps: the caller is a
+// pass that has just read the row, so "the row is gone" is a race worth
+// reporting rather than swallowing.
+func (r *Registry) SetDBPath(host, path string) error {
+ res, err := r.sql.Exec(`UPDATE routes SET db_path = ? WHERE host = ?`, path, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
// SetChannels repoints every host in hosts to channel as ONE transaction —
// every row moves or none do. `carlos route -app` uses this for its bulk
// form: the caller runs every route's safety-rail check to completion FIRST
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index aba9dc0b4..4a7da1d0f 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -1,6 +1,7 @@
package registry
import (
+ "database/sql"
"os"
"path/filepath"
"testing"
@@ -52,6 +53,12 @@ func TestListSeesOtherHandlesCommits(t *testing.T) {
}
}
+// TestUpsertRoundTripsAllColumns is the column-shift canary: routeCols,
+// scanRoute's destination list, Upsert's placeholder row and Upsert's
+// argument list are four hand-maintained lists that must agree, and a
+// miscount among them is a silent shift (a grant landing in created_at)
+// rather than an error. Every column carries a DISTINCT value here on
+// purpose — two columns sharing a value would let a swap pass.
func TestUpsertRoundTripsAllColumns(t *testing.T) {
reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
if err != nil {
@@ -63,8 +70,8 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) {
Status: "ready", LastError: "prior fail",
DBPath: "/data/b.db", Version: "abc1234", PrevVersion: "def5678",
App: "kass", Pool: "shared", Region: "ie", Channel: "stable",
- Environment: "demo",
- Hibernate: true, Canary: true, Replication: "batched",
+ Environment: "demo", Caps: "host-role,port:25",
+ Hibernate: true, Canary: true, Replication: "batched",
NextDue: 4200, Backing: BackingUnit, CreatedAt: 1700000000,
}
if err := reg.Upsert(want); err != nil {
@@ -778,3 +785,218 @@ func TestUpsertPreservesAnExistingEnvironmentBinding(t *testing.T) {
t.Fatalf("Environment = %q after an explicit clear, want it cleared", got.Environment)
}
}
+
+// A registry file written BEFORE the caps column existed must still open, and
+// its rows must read as "nothing granted" — the additive-migration contract
+// applied to the column that decides whether a unit gets box authority. The
+// pre-change file is simulated by dropping the column from a fresh registry
+// and writing a row through raw SQL, which is exactly the shape an older
+// binary left behind.
+func TestPreCapsRegistryOpensAndReadsUngranted(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ reg, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ old, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := old.Exec(`ALTER TABLE routes DROP COLUMN caps`); err != nil {
+ t.Fatalf("simulate a pre-caps registry: %v", err)
+ }
+ if _, err := old.Exec(`INSERT INTO routes (host, kind, status, created_at)
+VALUES ('legacy.example.com', 'instance', 'ready', 1700000000)`); err != nil {
+ t.Fatal(err)
+ }
+ if err := old.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ reg, err = Open(path)
+ if err != nil {
+ t.Fatalf("a pre-caps registry must still open: %v", err)
+ }
+ got, err := reg.Get("legacy.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil {
+ t.Fatal("the pre-existing row vanished across the migration")
+ }
+ if got.Caps != "" {
+ t.Fatalf("Caps = %q on a row written before the column existed, want %q — a migrated row must mean 'nothing granted'", got.Caps, "")
+ }
+ if got.Host != "legacy.example.com" || got.Kind != "instance" || got.CreatedAt != 1700000000 {
+ t.Fatalf("the migrated row shifted columns: %+v", *got)
+ }
+ // And the new column is writable on that same row.
+ if err := reg.SetCaps("legacy.example.com", "port:25"); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.Get("legacy.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Caps != "port:25" {
+ t.Fatalf("Caps = %q after SetCaps on a migrated row", got.Caps)
+ }
+}
+
+// SetCaps is narrow (it touches no other column) and refuses an unknown host
+// rather than silently doing nothing — `carlos route -grant` is a hostname a
+// human types, and a typo that reports success while granting nothing is the
+// worst possible outcome for this particular column.
+func TestSetCapsIsNarrowAndErrorsOnAnUnknownHost(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := Route{
+ Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: BackingUnit,
+ Channel: "canary/keymail", Environment: "prod", Version: "abc1234",
+ Status: "ready", NextDue: 99, DBPath: "/var/lib/carlos-app/keymail.dev/registry.db",
+ }
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetCaps("keymail.dev", "port:25"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := base
+ want.Caps = "port:25"
+ want.CreatedAt = got.CreatedAt
+ if *got != want {
+ t.Fatalf("SetCaps was not narrow:\n got %+v\n want %+v", *got, want)
+ }
+ if err := reg.SetCaps("nope.example.com", "port:25"); err == nil {
+ t.Fatal("SetCaps on an unknown host must error, not silently write nothing")
+ }
+}
+
+// SetDBPath is the converge pass's narrow stamp (it teaches ops.FleetDBs
+// where a platform-replicated app's databases live), with the same
+// unknown-host contract.
+func TestSetDBPathIsNarrowAndErrorsOnAnUnknownHost(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := Route{
+ Host: "console.example.com", Kind: "instance", App: "console",
+ Backing: BackingUnit, Channel: "canary/console", Status: "ready",
+ }
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetCaps("console.example.com", "host-role"); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetDBPath("console.example.com", "/var/lib/carlos-app/console.example.com/console.db"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("console.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := base
+ want.Caps = "host-role"
+ want.DBPath = "/var/lib/carlos-app/console.example.com/console.db"
+ want.CreatedAt = got.CreatedAt
+ if *got != want {
+ t.Fatalf("SetDBPath was not narrow:\n got %+v\n want %+v", *got, want)
+ }
+ if err := reg.SetDBPath("nope.example.com", "/x"); err == nil {
+ t.Fatal("SetDBPath on an unknown host must error")
+ }
+}
+
+// The named finding, as a test: `carlos add` re-runs and the domains
+// reconciler re-mints, and neither knows anything about grants. A plain
+// excluded.caps would silently REVOKE port:25 on a re-add — a capability
+// disappearing hours later, on the next converge pass, with nothing in the
+// operator's shell history connecting the two.
+func TestUpsertPreservesAnExistingGrant(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: BackingUnit, Status: "ready"}
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetCaps("keymail.dev", "host-role,port:25"); err != nil {
+ t.Fatal(err)
+ }
+ // The re-add / the re-mint: same row, no grants in hand.
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Caps != "host-role,port:25" {
+ t.Fatalf("Caps = %q after a re-Upsert, want the grant preserved", got.Caps)
+ }
+ // Preservation is a floor, not a freeze: an explicit non-empty value from
+ // a caller that genuinely knows the grants still wins.
+ base.Caps = "port:25"
+ if err := reg.Upsert(base); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.Get("keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Caps != "port:25" {
+ t.Fatalf("Caps = %q, want an explicit Upsert value to win", got.Caps)
+ }
+ // Revoking to nothing has exactly one door, and it is not Upsert.
+ if err := reg.SetCaps("keymail.dev", ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err = reg.Get("keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Caps != "" {
+ t.Fatalf("Caps = %q after an explicit revoke-to-empty, want it cleared", got.Caps)
+ }
+}
+
+// A grant changes unit shape, not routing: the edge has no reason to reload
+// for one, so the fingerprint must NOT move when caps does. (The inverse is
+// pinned by TestFingerprintChangesOnWrite for the columns that do matter.)
+func TestFingerprintIgnoresAGrant(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "registry.db")
+ reg, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: BackingUnit}); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetCaps("keymail.dev", "host-role,port:25"); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after != before {
+ t.Fatalf("Fingerprint moved on a grant (%s -> %s) — the edge would reload for a change that does not touch routing", before, after)
+ }
+}