eleven / ocho Public

server: billing drives the replication class — cloud maps tiers to rungs and pushes via controld (#869)

The policy half of the durability ladder (#866 shipped the mechanism). With
LCHAT_CLOUD_REPLICATION_CLASSES set (free=daily,family=daily,community=batched,
business=stream — tier keys from the price book plus 'free'), the cloud
provisioner keeps every ledger space on its plan's rung:

- born classed: the create request carries the free class, so a fresh space
  never spends an awake window on the streaming rung
- webhook pushes: checkout + subscription events re-push on every entitlement
  move; a lapse drops the space back to the free rung
- sweep reconcile: the meter sweep re-pushes each pass; controld no-ops (and
  skips the router reload) when the class already matches

controld gains POST /instances/{host}/replication: validates the class,
records it on the registry route, reloads the router only on actual change.
Nothing restarts and no sleeping space wakes — the class applies at next wake.

An unknown PAID tier maps to stream deliberately: a config typo must fail
toward durability, never away from what the member paid for. Unset env = no
pushes anywhere (the pre-ladder status quo); operator-run instances outside
the ledger are never touched.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f38421c4651656c319b9471dbc08f4a1aade1c06 parent 194dae5
8 files changed, +472 −8
  • billing.go +4 −2
  • cloud.go +20 −6
  • cloudreplication.go +112 −0
  • cloudreplication_test.go +146 −0
  • controld.go +63 −0
  • controld_test.go +106 −0
  • docs/cloud-provisioner.md +18 −0
  • meter.go +3 −0
diff --git a/billing.go b/billing.go
index 1e14570..d54ac64 100644
--- a/billing.go
+++ b/billing.go
@@ -405,7 +405,8 @@ func (s *cloudServer) handleCheckoutCompleted(raw json.RawMessage) error {
switch err := s.db.markSpacePaid(id, obj.Customer, obj.Subscription, obj.Metadata["tier"]); err {
case nil:
log.Printf("cloud: space id %d upgraded (checkout complete, tier %s)", id, obj.Metadata["tier"])
- s.pushQuotaByID(id) // raise the space's storage cap to its new tier
+ s.pushQuotaByID(id) // raise the space's storage cap to its new tier
+ s.pushReplicationByID(id) // move it onto its tier's durability rung
return nil
case sql.ErrNoRows:
log.Printf("cloud: webhook checkout for unknown space id %d — ignoring", id)
@@ -479,7 +480,8 @@ func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage
switch err := s.db.setEntitlementTier(id, ent, tier, paidUntil, obj.CancelAtPeriodEnd); err {
case nil:
log.Printf("cloud: space id %d entitlement → %s (tier %s, subscription %s, cancelling=%v)", id, ent, tier, obj.Status, obj.CancelAtPeriodEnd)
- s.pushQuotaByID(id) // entitlement/tier moved — re-apply the matching cap
+ s.pushQuotaByID(id) // entitlement/tier moved — re-apply the matching cap
+ s.pushReplicationByID(id) // and the matching durability rung (lapse = back to free's)
return nil
case sql.ErrNoRows:
log.Printf("cloud: webhook %s for unknown space id %d — ignoring", evType, id)
diff --git a/cloud.go b/cloud.go
index d8c6f20..047e712 100644
--- a/cloud.go
+++ b/cloud.go
@@ -83,7 +83,10 @@ type cloudServer struct {
reapMax int // max reclaims per sweep (blast-radius cap; 0 = unlimited)
billing *stripeBilling // optional space upgrades (billing.go); nil = not offered
storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement
- createBudget int // max creates fleet-wide per trailing 24h (0 = uncapped)
+ // replicationClasses maps tier keys (+ "free") to durability-ladder classes
+ // (cloudreplication.go); empty = classes never pushed, every space streams.
+ replicationClasses map[string]string
+ createBudget int // max creates fleet-wide per trailing 24h (0 = uncapped)
}
func (s *cloudServer) handler() http.Handler {
@@ -237,9 +240,12 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request)
// controlCreate posts one creation request to the control daemon. Every
// provisioned space is born free — the entitlement lands in the instance env
// so its UI can offer Upgrade from day one; billing flips re-push via the
-// quota path.
+// quota path. The replication class rides along so the space is born on the
+// free rung of the durability ladder (billing flips re-push via
+// pushReplicationByID).
func (s *cloudServer) controlCreate(host string) (int, map[string]any, error) {
- payload, _ := json.Marshal(controlCreateReq{Host: host, Entitlement: entFree})
+ payload, _ := json.Marshal(controlCreateReq{Host: host, Entitlement: entFree,
+ Replication: s.replicationFor(entFree, "")})
req, err := http.NewRequest("POST", "http://control/instances", bytes.NewReader(payload))
if err != nil {
return 0, nil, err
@@ -438,6 +444,10 @@ func runCloud(args []string) {
if err != nil {
die("cloud: LCHAT_CLOUD_STORAGE_CAPS: %v", err)
}
+ replicationClasses, err := parseReplicationClasses(os.Getenv("LCHAT_CLOUD_REPLICATION_CLASSES"))
+ if err != nil {
+ die("cloud: LCHAT_CLOUD_REPLICATION_CLASSES: %v", err)
+ }
billing, err := newStripeBilling(
os.Getenv("LCHAT_STRIPE_SECRET_KEY"),
os.Getenv("LCHAT_STRIPE_WEBHOOK_SECRET"),
@@ -461,9 +471,10 @@ func runCloud(args []string) {
reapAfter: time.Duration(*reapAfterDays) * 24 * time.Hour,
reapDryRun: *reapDryRun,
reapMax: *reapMax,
- billing: billing,
- storageCaps: storageCaps,
- createBudget: *createBudget,
+ billing: billing,
+ storageCaps: storageCaps,
+ replicationClasses: replicationClasses,
+ createBudget: *createBudget,
}
if *createBudget > 0 {
log.Printf("cloud: creation budget on (%d/day fleet-wide)", *createBudget)
@@ -471,6 +482,9 @@ func runCloud(args []string) {
if len(storageCaps) > 0 {
log.Printf("cloud: storage caps on (%d entries)", len(storageCaps))
}
+ if len(replicationClasses) > 0 {
+ log.Printf("cloud: replication classes on (%d entries)", len(replicationClasses))
+ }
if billing != nil {
log.Printf("cloud: billing on (%d tiers)", len(billing.tiers))
}
diff --git a/cloudreplication.go b/cloudreplication.go
new file mode 100644
index 0000000..43a6a7c
--- /dev/null
+++ b/cloudreplication.go
@@ -0,0 +1,112 @@
+package main
+
+// Billing → replication class (the policy half of the durability ladder;
+// registry.go has the mechanism). The cloud provisioner maps each space's
+// billing state to a replication class and pushes it to controld, which
+// records it on the registry route — the same event-driven-plus-sweep shape
+// as storage caps (meter.go): pushed at creation (born classed), on every
+// Stripe entitlement move, and reconciled by the meter sweep as the
+// self-healing backstop. Classes are config (LCHAT_CLOUD_REPLICATION_CLASSES);
+// no config = no pushes anywhere, and operator-run instances outside the
+// ledger are never touched.
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+)
+
+// parseReplicationClasses reads LCHAT_CLOUD_REPLICATION_CLASSES:
+// "free=daily,family=daily,community=batched,business=stream" — keys are tier
+// keys from the price book, plus "free" for unpaid spaces (the same key
+// convention as LCHAT_CLOUD_STORAGE_CAPS).
+func parseReplicationClasses(v string) (map[string]string, error) {
+ if strings.TrimSpace(v) == "" {
+ return nil, nil
+ }
+ classes := map[string]string{}
+ for _, pair := range strings.Split(v, ",") {
+ k, val, ok := strings.Cut(strings.TrimSpace(pair), "=")
+ if !ok || strings.TrimSpace(k) == "" {
+ return nil, fmt.Errorf("bad entry %q (want key=class)", pair)
+ }
+ class := strings.ToLower(strings.TrimSpace(val))
+ if class == "" || !validReplication(class) {
+ return nil, fmt.Errorf("class %q: want %s, %s or %s", val,
+ replicationStream, replicationBatched, replicationDaily)
+ }
+ classes[strings.TrimSpace(k)] = class
+ }
+ return classes, nil
+}
+
+// replicationFor maps a space's billing state to its replication class.
+// "" = nothing to push (classes unconfigured, or free with no "free" entry —
+// the host then keeps whatever it has, which for an unclassed route is
+// stream). A PAID tier missing from the map gets stream, never a downgrade:
+// a misspelled tier key in config must fail toward durability, not away
+// from what the member paid for.
+func (s *cloudServer) replicationFor(entitlement, tier string) string {
+ if len(s.replicationClasses) == 0 {
+ return ""
+ }
+ if entitlement == entPaid {
+ if class, ok := s.replicationClasses[tier]; ok {
+ return class
+ }
+ return replicationStream
+ }
+ return s.replicationClasses["free"]
+}
+
+// controlSetReplication pushes one instance's replication class to controld.
+func (s *cloudServer) controlSetReplication(host, class string) error {
+ payload, _ := json.Marshal(map[string]any{"class": class})
+ req, err := http.NewRequest("POST", "http://control/instances/"+host+"/replication", bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+s.controlToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := s.control.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("control replication %s: HTTP %d", host, resp.StatusCode)
+ }
+ return nil
+}
+
+// pushReplication applies a space's tier class to its instance. Best-effort
+// like pushQuota: a failed push logs and stands down — the meter sweep
+// reconciles it on the next pass. Unlike quota there is no ledger record to
+// compare against; controld no-ops (and skips the router reload) when the
+// class already matches, so re-pushing every sweep is a cheap localhost POST.
+func (s *cloudServer) pushReplication(host, entitlement, tier string) {
+ class := s.replicationFor(entitlement, tier)
+ if class == "" {
+ return
+ }
+ if err := s.controlSetReplication(host, class); err != nil {
+ log.Printf("cloud: replication push %s: %v (sweep will retry)", host, err)
+ return
+ }
+}
+
+// pushReplicationByID looks a space's billing state up and applies its class —
+// the webhook-side entry point, beside pushQuotaByID.
+func (s *cloudServer) pushReplicationByID(id int64) {
+ if len(s.replicationClasses) == 0 {
+ return
+ }
+ m, err := s.db.spaceBillingByID(id)
+ if err != nil || m.Host == "" {
+ return // pending create or gone — the sweep reconciles survivors
+ }
+ s.pushReplication(m.Host, m.Entitlement, m.Tier)
+}
diff --git a/cloudreplication_test.go b/cloudreplication_test.go
new file mode 100644
index 0000000..66218b5
--- /dev/null
+++ b/cloudreplication_test.go
@@ -0,0 +1,146 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+)
+
+func TestParseReplicationClasses(t *testing.T) {
+ got, err := parseReplicationClasses("free=daily,family=daily,community=batched,business=stream")
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[string]string{"free": "daily", "family": "daily", "community": "batched", "business": "stream"}
+ if len(got) != len(want) {
+ t.Fatalf("parsed %v, want %v", got, want)
+ }
+ for k, v := range want {
+ if got[k] != v {
+ t.Errorf("classes[%q] = %q, want %q", k, got[k], v)
+ }
+ }
+
+ // Unset = feature off, not an error.
+ if m, err := parseReplicationClasses(""); err != nil || m != nil {
+ t.Errorf("empty env = (%v, %v), want (nil, nil)", m, err)
+ }
+ // A class the router doesn't know must fail at startup, not silently
+ // stream forever on the box.
+ if _, err := parseReplicationClasses("free=hourly"); err == nil {
+ t.Error("unknown class accepted")
+ }
+ if _, err := parseReplicationClasses("free"); err == nil {
+ t.Error("entry without = accepted")
+ }
+ // An EMPTY class value is rejected too — "" is valid to validReplication
+ // (it's the unset registry default) but meaningless as config.
+ if _, err := parseReplicationClasses("free="); err == nil {
+ t.Error("empty class value accepted")
+ }
+}
+
+// replicationFor is the policy: configured keys act, free without a "free"
+// entry pushes nothing, and an unknown PAID tier fails toward durability
+// (stream) — a config typo must never quietly put a paying space on the
+// nightly rung.
+func TestReplicationFor(t *testing.T) {
+ s := &cloudServer{replicationClasses: map[string]string{
+ "free": "daily", "family": "daily", "community": "batched", "business": "stream",
+ }}
+ for _, tc := range []struct {
+ ent, tier, want string
+ }{
+ {entFree, "", "daily"},
+ {entFree, "family", "daily"}, // stale tier on a lapsed row: entitlement wins
+ {entPaid, "family", "daily"},
+ {entPaid, "community", "batched"},
+ {entPaid, "business", "stream"},
+ {entPaid, "buisness", "stream"}, // typo'd/unknown paid tier → stream, never a downgrade
+ } {
+ if got := s.replicationFor(tc.ent, tc.tier); got != tc.want {
+ t.Errorf("replicationFor(%q, %q) = %q, want %q", tc.ent, tc.tier, got, tc.want)
+ }
+ }
+
+ // Unconfigured = push nothing anywhere.
+ off := &cloudServer{}
+ if got := off.replicationFor(entPaid, "business"); got != "" {
+ t.Errorf("unconfigured replicationFor = %q, want \"\"", got)
+ }
+ // Configured but no "free" key: free spaces keep whatever they have.
+ partial := &cloudServer{replicationClasses: map[string]string{"business": "stream"}}
+ if got := partial.replicationFor(entFree, ""); got != "" {
+ t.Errorf("free with no free entry = %q, want \"\"", got)
+ }
+}
+
+// replicationControl fakes controld's stats + replication endpoints, recording
+// class pushes (the stats half keeps meterOnce's sweep loop running).
+func replicationControl(t *testing.T, pushed map[string]string) string {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": int64(1)})
+ })
+ mux.HandleFunc("POST /instances/{host}/replication", func(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Class string `json:"class"`
+ }
+ json.NewDecoder(r.Body).Decode(&req)
+ pushed[r.PathValue("host")] = req.Class
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "changed": true})
+ })
+ return fakeControl(t, mux.ServeHTTP)
+}
+
+// The class enforcement loop, mirroring TestQuotaPushOnUpgradeAndSweep: a
+// checkout upgrade pushes the tier's class immediately, the meter sweep
+// reconciles everyone, and a lapse drops the space back to the free rung.
+func TestReplicationPushOnUpgradeAndSweep(t *testing.T) {
+ db := openTestCloudDB(t)
+ paidID := insertSpaceRow(t, db, "paid.11ch.at", 1000)
+ insertSpaceRow(t, db, "free.11ch.at", 1000)
+
+ pushed := map[string]string{}
+ sock := replicationControl(t, pushed)
+ s := &cloudServer{
+ db: db, control: controlClient(sock), controlToken: "t",
+ replicationClasses: map[string]string{"free": "daily", "business": "stream"},
+ }
+
+ // The upgrade path (what the checkout webhook calls).
+ if err := db.markSpacePaid(paidID, "cus", "sub", "business"); err != nil {
+ t.Fatal(err)
+ }
+ s.pushReplicationByID(paidID)
+ if pushed["paid.11ch.at"] != "stream" {
+ t.Fatalf("upgrade push = %q, want stream", pushed["paid.11ch.at"])
+ }
+
+ // The sweep reconciles both (controld's changed=false makes re-pushes
+ // harmless, so unlike quota there is no pushed-state comparison here).
+ s.meterOnce()
+ if pushed["free.11ch.at"] != "daily" {
+ t.Fatalf("sweep push = %q, want daily", pushed["free.11ch.at"])
+ }
+
+ // A lapse (subscription deleted → free) re-applies the free rung.
+ if err := db.setEntitlement(paidID, entFree, 0); err != nil {
+ t.Fatal(err)
+ }
+ s.pushReplicationByID(paidID)
+ if pushed["paid.11ch.at"] != "daily" {
+ t.Fatalf("lapse push = %q, want back to daily", pushed["paid.11ch.at"])
+ }
+
+ // No classes configured → nothing pushes anywhere.
+ pushed2 := map[string]string{}
+ sock2 := replicationControl(t, pushed2)
+ s2 := &cloudServer{db: db, control: controlClient(sock2), controlToken: "t"}
+ s2.meterOnce()
+ s2.pushReplicationByID(paidID)
+ if len(pushed2) != 0 {
+ t.Errorf("unconfigured classes pushed: %v", pushed2)
+ }
+}
diff --git a/controld.go b/controld.go
index 9062879..3fe772f 100644
--- a/controld.go
+++ b/controld.go
@@ -84,6 +84,9 @@ type controlCreateReq struct {
PushRelayURL string `json:"push_relay_url,omitempty"`
PushRelayToken string `json:"push_relay_token,omitempty"`
Entitlement string `json:"entitlement,omitempty"`
+ // Replication classes the space at birth (registry.go's durability ladder)
+ // so a free space never spends a single awake window on the streaming rung.
+ Replication string `json:"replication,omitempty"`
}
// controlErr is the wire error shape: a stable machine code plus a human
@@ -103,10 +106,69 @@ func (c *controld) handler() http.Handler {
mux.HandleFunc("DELETE /instances/{host}", c.auth(c.deleteHandler))
mux.HandleFunc("GET /instances/{host}/stats", c.auth(c.statsHandler))
mux.HandleFunc("POST /instances/{host}/quota", c.auth(c.quotaHandler))
+ mux.HandleFunc("POST /instances/{host}/replication", c.auth(c.replicationHandler))
mux.HandleFunc("POST /instances/{host}/materialize", c.auth(c.materializeHandler))
return mux
}
+// replicationHandler records one instance's replication class on its registry
+// route (the durability ladder — registry.go) and reloads the router so the
+// next wake reads it. The class is wake-time config, so nothing is restarted
+// and no sleeping space is woken: an awake instance keeps its current sync
+// tick until it next sleeps. Idempotent, and deliberately reload-free when the
+// class already matches — the cloud sweep re-pushes every pass, and a router
+// reload per space per sweep would be pointless churn.
+func (c *controld) replicationHandler(w http.ResponseWriter, r *http.Request) {
+ host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
+ if !validHost(host) {
+ writeControlErr(w, http.StatusBadRequest, "invalid_host", "not a valid hostname")
+ return
+ }
+ var req struct {
+ Class string `json:"class"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
+ return
+ }
+ class := strings.ToLower(strings.TrimSpace(req.Class))
+ if class == "" || !validReplication(class) {
+ writeControlErr(w, http.StatusBadRequest, "bad_request",
+ fmt.Sprintf("class must be %s, %s or %s", replicationStream, replicationBatched, replicationDaily))
+ return
+ }
+ rt, err := c.reg.get(host)
+ if err != nil {
+ log.Printf("controld: replication %s: lookup: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "lookup_failed", "db error")
+ return
+ }
+ if rt == nil || rt.Kind != "instance" {
+ writeControlErr(w, http.StatusNotFound, "unknown_host", "no such instance")
+ return
+ }
+ if rt.Replication == class {
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "changed": false})
+ return
+ }
+ if err := c.reg.setReplication(host, class); err != nil {
+ log.Printf("controld: replication %s: %v", host, err)
+ writeControlErr(w, http.StatusInternalServerError, "replication_failed", err.Error())
+ return
+ }
+ // Best-effort: the registry write is durable either way, and the sweep's
+ // next push finds changed=false and stops — so a failed reload here must be
+ // loud (it's the only convergence signal left until something else reloads
+ // the router).
+ reloaded := true
+ if err := systemctl("reload", "lchat-router"); err != nil {
+ reloaded = false
+ log.Printf("controld: replication %s → %s: router reload FAILED: %v (class is stored but inert until a reload)", host, class, err)
+ }
+ log.Printf("controld: replication %s → %s (reloaded=%v)", host, class, reloaded)
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "changed": true, "reloaded": reloaded})
+}
+
// materializeHandler stages the running control binary and points host's live
// symlink at it, if not already done. This is the privileged half of a route's
// first wake on a box that never locally created it (control.go's
@@ -368,6 +430,7 @@ func (c *controld) createHandler(w http.ResponseWriter, r *http.Request) {
PushRelayURL: req.PushRelayURL,
PushRelayToken: req.PushRelayToken,
Entitlement: req.Entitlement,
+ Replication: strings.ToLower(strings.TrimSpace(req.Replication)),
// Hibernate-by-default (#550): with LCHAT_CONTROL_HIBERNATE_CREATES=1
// every provisioner-created space is born activator-managed — the
// create's public-readiness probe is its one (first) wake, minting the
diff --git a/controld_test.go b/controld_test.go
index 116ff57..386ae47 100644
--- a/controld_test.go
+++ b/controld_test.go
@@ -155,6 +155,112 @@ func socketTripwire(t *testing.T, host string) *atomic.Int32 {
return &dials
}
+// The replication endpoint: records the class on the registry route and
+// reloads the router exactly once per actual change — a matching class is a
+// no-op with no reload (the cloud sweep re-pushes every pass), and garbage
+// classes / unknown hosts never touch anything.
+func TestControldReplicationEndpoint(t *testing.T) {
+ reg := controlTestEnv(t)
+ if err := reg.upsert(Route{Host: "space.11ch.at", Kind: "instance", Socket: "/run/s.sock", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.upsert(Route{Host: "svc.11ch.at", Kind: "service", Socket: "/run/v.sock"}); err != nil {
+ t.Fatal(err)
+ }
+ srv, token := newTestControld(t, reg, 40)
+
+ var reloads int
+ systemctl = func(args ...string) error {
+ if len(args) == 2 && args[0] == "reload" {
+ reloads++
+ }
+ return nil
+ }
+
+ post := func(host, class, tok string) *http.Response {
+ t.Helper()
+ body, _ := json.Marshal(map[string]string{"class": class})
+ req, _ := http.NewRequest("POST", srv.URL+"/instances/"+host+"/replication", bytes.NewReader(body))
+ if tok != "" {
+ req.Header.Set("Authorization", "Bearer "+tok)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+ }
+
+ if resp := post("space.11ch.at", "daily", ""); resp.StatusCode != http.StatusUnauthorized {
+ t.Errorf("no token: status = %d, want 401", resp.StatusCode)
+ }
+ if resp := post("space.11ch.at", "hourly", token); resp.StatusCode != http.StatusBadRequest {
+ t.Errorf("bad class: status = %d, want 400", resp.StatusCode)
+ }
+ if resp := post("ghost.11ch.at", "daily", token); resp.StatusCode != http.StatusNotFound {
+ t.Errorf("unknown host: status = %d, want 404", resp.StatusCode)
+ }
+ if resp := post("svc.11ch.at", "daily", token); resp.StatusCode != http.StatusNotFound {
+ t.Errorf("service route: status = %d, want 404", resp.StatusCode)
+ }
+ if rt, _ := reg.get("space.11ch.at"); rt.Replication != "" || reloads != 0 {
+ t.Fatalf("rejected requests left marks: class=%q reloads=%d", rt.Replication, reloads)
+ }
+
+ resp := post("space.11ch.at", "daily", token)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("set: status = %d, want 200", resp.StatusCode)
+ }
+ if body := decodeBody(t, resp); body["changed"] != true {
+ t.Errorf("set: changed = %v, want true", body["changed"])
+ }
+ if rt, _ := reg.get("space.11ch.at"); rt.Replication != "daily" {
+ t.Fatalf("class not recorded: %q", rt.Replication)
+ }
+ if reloads != 1 {
+ t.Fatalf("reloads after set = %d, want 1", reloads)
+ }
+
+ // Idempotent re-push (the sweep's steady state): no change, no reload.
+ resp = post("space.11ch.at", "daily", token)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("re-push: status = %d, want 200", resp.StatusCode)
+ }
+ if body := decodeBody(t, resp); body["changed"] != false {
+ t.Errorf("re-push: changed = %v, want false", body["changed"])
+ }
+ if reloads != 1 {
+ t.Fatalf("re-push reloaded the router: %d reloads", reloads)
+ }
+}
+
+// A create request carrying a replication class produces a route born on that
+// rung (what the provisioner sends for a fresh free space).
+func TestControldCreateBornClassed(t *testing.T) {
+ reg := controlTestEnv(t)
+ srv, token := newTestControld(t, reg, 40)
+
+ body, _ := json.Marshal(map[string]string{"host": "born.11ch.at", "replication": "daily"})
+ req, _ := http.NewRequest("POST", srv.URL+"/instances", bytes.NewReader(body))
+ req.Header.Set("Authorization", "Bearer "+token)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("create: status = %d, want 201", resp.StatusCode)
+ }
+ rt, err := reg.get("born.11ch.at")
+ if err != nil || rt == nil {
+ t.Fatalf("route: %+v, %v", rt, err)
+ }
+ if rt.Replication != "daily" {
+ t.Errorf("born class = %q, want daily", rt.Replication)
+ }
+}
+
func TestControldRequiresToken(t *testing.T) {
reg := controlTestEnv(t)
srv, _ := newTestControld(t, reg, 40)
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index 7f98173..8d67468 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -359,6 +359,24 @@ it. Unset = no enforcement, and operator-run instances (not in this ledger)
are never touched. Caps are sized to the box today (see
`tier-economics.md`); they rise when attachments move off-box (#550).
+**Replication classes (the durability ladder).** With
+`LCHAT_CLOUD_REPLICATION_CLASSES` set
+(`free=daily,family=daily,community=batched,business=stream` — same key
+convention as storage caps), the cloud maps each ledger space's billing state
+to a replication class on the activator's S3 replica and pushes it via
+controld (`POST /instances/{host}/replication` → the registry route + a router
+reload, only on actual change). Same event-driven-plus-sweep shape as caps:
+spaces are **born classed** (the create request carries the free class),
+Stripe webhooks re-push on every entitlement move (a lapse drops the space
+back to the free rung), and the meter sweep reconciles drift. The class is
+wake-time config — nothing is restarted, no sleeping space is woken, and an
+awake instance keeps its current sync tick until it next sleeps. An unknown
+**paid** tier maps to `stream` on purpose: a config typo must fail toward
+durability, never away from what the member paid for. Unset = no pushes,
+every space streams (the pre-ladder status quo). What the classes mean —
+sync cadence, loss windows, and the graceful-flush guarantee — lives with
+the mechanism in `registry.go` and the ops guide.
+
## Billing (Stripe) — tiers, quota exemption, no metering
Optional space upgrades (`billing.go`): a person who created a space can pay a
diff --git a/meter.go b/meter.go
index 83a0267..f869f76 100644
--- a/meter.go
+++ b/meter.go
@@ -106,6 +106,9 @@ func (s *cloudServer) meterOnce() (metered int) {
if want := s.capFor(sp.Entitlement, sp.Tier); want > 0 && want != sp.QuotaBytes {
s.pushQuota(sp.ID, sp.Host, sp.Entitlement, sp.Tier)
}
+ // Replication class reconcile (cloudreplication.go): no ledger record
+ // to compare, so push each pass — controld no-ops on a matching class.
+ s.pushReplication(sp.Host, sp.Entitlement, sp.Tier)
}
if metered > 0 {
log.Printf("cloud: meter: refreshed %d space(s)", metered)