carlos / platform Public

The flush-only replication class (hibernate-flush): a fourth rung, replicated at wake and at sleep only

The approved pricing design gives the Free plan hibernate-flush durability
and says the class must be added, because the unset value means stream. The
registry's replication vocabulary gains hibernate-flush (the entitlement
record's word), valid for the column and listed last; the activator maps it
to a sync interval no awake period reaches, so litestream replicates at the
wake-time pass and at Close and at no time between. Nothing sets it from a
plan: who is Free, and from when, is not decided.

A lease test runs litestream against a local file replica: seeded rows
reach the replica at the wake; rows written while awake do not, for longer
than the default interval needed (the control); Close replicates them all.
Two mutants killed (the class mapped to 0; the word not valid).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0e8ea2271a5259e39d71880687d92af36b2018e9 parent 6536355
7 files changed, +202 −8
  • docs/superpowers/specs/2026-10-02-flush-only-replication-class.md +29 −0
  • internal/activator/activator.go +14 −2
  • internal/activator/activator_test.go +3 −0
  • internal/console/replicationclass_test.go +23 −0
  • internal/lease/flushonly_test.go +117 −0
  • internal/registry/registry.go +11 −2
  • internal/registry/replication_test.go +5 −4

CI failed — run details

diff --git a/docs/superpowers/specs/2026-10-02-flush-only-replication-class.md b/docs/superpowers/specs/2026-10-02-flush-only-replication-class.md
new file mode 100644
index 000000000..698629072
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-02-flush-only-replication-class.md
@@ -0,0 +1,29 @@
+# The flush-only replication class
+
+Status: built on `feat/carloku-flush-only-class-20261002` (off the paid-launch branch), 2026-10-02. Nothing sets the class from a plan; nothing is deployed.
+
+The approved pricing design (`2026-09-07-carloku-pricing-plans-design.md`, "The free tier and the pool") says: "Durability on free: hibernate-flush only", and "This needs a new replication class. The existing classes are stream, five-minute and daily, and the unset value means stream, so a free app left alone would replicate continuously, the opposite of the intent. Flush-only must be added as a class and propagated from the account's plan." This branch adds the class. It does not propagate it: which accounts are Free, and from when, is not decided (`OUTSTANDING.md` 15).
+
+## What it is
+
+A fourth rung of the registry's `routes.replication` vocabulary, `hibernate-flush`, the word the entitlement record already carries (`entitlement.ReplicationHibernateFlush`). The activator maps it to a litestream sync interval no awake period reaches (ten years), so the database is replicated at the wake-time pass and at the sleep-time flush, both of which `lease.Replicate` and `Replicator.Close` run in full whatever the interval, and at no time between.
+
+- Loss window: everything written since the instance woke, if the host is lost while it is awake. Hibernation is durable, as for every class.
+- An operator can set it (`carlos route --replication hibernate-flush`); `SetReplication` accepts it; it is listed last, the least durable rung.
+- A wake-time pass that fails is not retried until the sleep-time flush (litestream retries on the interval's tick). For `daily` that is up to a day; here it is until sleep.
+- An instance that never sleeps never replicates after its wake under this class. The class is meant for a plan that hibernates at two minutes idle.
+
+## Mixed versions
+
+An older activator reads an unknown class as litestream's default (the stream class): a route set to `hibernate-flush` on a box whose binary predates this replicates continuously, which is more durable and more S3 traffic, never less durable. An older CLI refuses the name.
+
+## Tests
+
+- `internal/lease` `TestALongSyncIntervalReplicatesAtWakeAndAtCloseOnly`: litestream against a local file replica. The seeded rows reach the replica at the wake with no call; rows written while awake do not, for longer than the default interval needed to replicate the same writes (the control); Close replicates them all. It shows when litestream syncs, not what S3 does.
+- `internal/activator` `TestActivatorWakeThreadsReplicationClass`: the class threads the long interval, not 0.
+- `internal/registry` `TestValidReplicationVocabulary`: the word is valid, near-misses are not, four rungs in order.
+- `internal/console` `TestEntitlementReplicationClassesAreRegistryClasses`: the entitlement's classes are the registry's.
+
+## Not in this branch
+
+Propagating a plan's class to its routes; the Free entitlement; the per-plan idle window; the app cap; storage refusal. Each is listed in `OUTSTANDING.md` 15 with the decision it waits on.
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index b8889e66a..752ef1040 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -292,8 +292,8 @@ func procCrashed(p Proc) bool {
}
// Replication classes — the registry's routes.replication vocabulary
-// ("" | stream | batched | daily), mapped to litestream sync intervals by
-// replicationSyncInterval.
+// ("" | stream | batched | daily | hibernate-flush), mapped to litestream
+// sync intervals by replicationSyncInterval.
//
// Aliases, not copies: internal/registry owns the vocabulary now that
// `carlos route --replication` writes the column against it (discussion
@@ -303,8 +303,18 @@ const (
replicationStream = registry.ReplicationStream // change-driven ~1s sync — loss window seconds
replicationBatched = registry.ReplicationBatched // sync at most every 5m — loss window minutes
replicationDaily = registry.ReplicationDaily // sync at wake + every 24h — loss window up to a day
+ // sync at wake and at sleep only — loss window: everything since the wake
+ replicationHibernateFlush = registry.ReplicationHibernateFlush
)
+// flushOnlySyncInterval is the hibernate-flush class's tick: a spacing no
+// awake period reaches, so between the wake-time pass and the sleep-time
+// flush (both of which run in full whatever the interval: lease.Replicate)
+// litestream makes no replica sync at all. Ten years, not the largest
+// Duration: a ticker is armed with it, and arithmetic on it must not
+// overflow.
+const flushOnlySyncInterval = 10 * 365 * 24 * time.Hour
+
// replicationSyncInterval maps a class to the litestream replica sync
// interval it buys. 0 means "keep litestream's default" (1s, change-driven —
// idle ticks make zero S3 calls), which is what stream and the unset ""
@@ -315,6 +325,8 @@ func replicationSyncInterval(class string) time.Duration {
return 5 * time.Minute
case replicationDaily:
return 24 * time.Hour
+ case replicationHibernateFlush:
+ return flushOnlySyncInterval
}
return 0
}
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index c27ccd6ca..7260dad62 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -970,6 +970,9 @@ func TestActivatorWakeThreadsReplicationClass(t *testing.T) {
{replicationStream, 0},
{replicationBatched, 5 * time.Minute},
{replicationDaily, 24 * time.Hour},
+ // The Free plan's class: no tick an awake period reaches. Not 0,
+ // which would be the stream class, the opposite of its intent.
+ {replicationHibernateFlush, flushOnlySyncInterval},
} {
rt := testRoute(fmt.Sprintf("ladder%d.example.com", i))
rt.Replication = tc.class
diff --git a/internal/console/replicationclass_test.go b/internal/console/replicationclass_test.go
new file mode 100644
index 000000000..ed2dbb681
--- /dev/null
+++ b/internal/console/replicationclass_test.go
@@ -0,0 +1,23 @@
+package console
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/entitlement"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// The class an entitlement record pins and the class a route's column holds
+// are one vocabulary: a plan's replication class is written to routes with
+// the registry's words, and a record that says hibernate-flush must name a
+// class the registry accepts and the activator maps.
+func TestEntitlementReplicationClassesAreRegistryClasses(t *testing.T) {
+ for _, class := range []string{entitlement.ReplicationStream, entitlement.ReplicationBatched, entitlement.ReplicationDaily, entitlement.ReplicationHibernateFlush} {
+ if !registry.ValidReplication(class) {
+ t.Errorf("the entitlement's class %q is not one the registry's replication column may hold", class)
+ }
+ }
+ if entitlement.ReplicationHibernateFlush != registry.ReplicationHibernateFlush {
+ t.Fatalf("two spellings of the flush-only class: %q and %q", entitlement.ReplicationHibernateFlush, registry.ReplicationHibernateFlush)
+ }
+}
diff --git a/internal/lease/flushonly_test.go b/internal/lease/flushonly_test.go
new file mode 100644
index 000000000..760bcc045
--- /dev/null
+++ b/internal/lease/flushonly_test.go
@@ -0,0 +1,117 @@
+package lease
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "io/fs"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ lsfile "github.com/benbjohnson/litestream/file"
+)
+
+// The hibernate-flush class rests on one property of Replicate's sync
+// interval: with an interval no awake period reaches, the database is
+// replicated at the wake-time pass and at Close, and at no time between.
+// The control runs the same writes under the default interval (the stream
+// class) and sees them replicated while the store is still open, so the
+// wait below is long enough to have shown a sync had one happened.
+//
+// It runs litestream against a local file replica, not S3: what it shows is
+// when litestream syncs, not what S3 does.
+func TestALongSyncIntervalReplicatesAtWakeAndAtCloseOnly(t *testing.T) {
+ const longInterval = 10 * 365 * 24 * time.Hour // the activator's flushOnlySyncInterval
+ ltxFiles := func(dir string) int {
+ n := 0
+ _ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
+ if err == nil && !d.IsDir() && strings.HasSuffix(p, ".ltx") {
+ n++
+ }
+ return nil
+ })
+ return n
+ }
+ start := func(t *testing.T, interval time.Duration) (*Replicator, string, string) {
+ t.Helper()
+ h := localHeld(t, &lockStore{}, time.Minute)
+ path := filepath.Join(t.TempDir(), "source.db")
+ seedDB(t, path)
+ replica := t.TempDir()
+ h.replicaClient = lsfile.NewReplicaClient(replica)
+ rep, err := h.Replicate(context.Background(), path, interval)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The wake-time pass: the seeded rows reach the replica with no
+ // call from here.
+ deadline := time.Now().Add(40 * time.Second)
+ for ltxFiles(replica) == 0 {
+ if time.Now().After(deadline) {
+ t.Fatal("the wake-time pass replicated nothing")
+ }
+ time.Sleep(250 * time.Millisecond)
+ }
+ if n := restoreCount(t, replica, filepath.Join(t.TempDir(), "at-wake.db")); n != 50 {
+ t.Fatalf("the wake-time pass restored %d rows; want the 50 seeded", n)
+ }
+ return rep, path, replica
+ }
+ write := func(t *testing.T, path string) {
+ t.Helper()
+ d, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ for i := 0; i < 10; i++ {
+ if _, err := d.Exec(`INSERT INTO t (body) VALUES (?)`, fmt.Sprintf("awake-%d", i)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ var streamTook time.Duration
+ t.Run("the default interval replicates while awake (the control)", func(t *testing.T) {
+ rep, path, replica := start(t, 0)
+ defer rep.Close(context.Background())
+ before := ltxFiles(replica)
+ write(t, path)
+ began := time.Now()
+ for ltxFiles(replica) == before {
+ if time.Since(began) > 40*time.Second {
+ t.Fatal("the default interval replicated nothing while awake: the control does not hold, so the long-interval wait proves nothing")
+ }
+ time.Sleep(250 * time.Millisecond)
+ }
+ streamTook = time.Since(began)
+ })
+ if streamTook == 0 {
+ t.Fatal("no control")
+ }
+ t.Run("the long interval replicates nothing while awake, and everything at Close", func(t *testing.T) {
+ rep, path, replica := start(t, longInterval)
+ before := ltxFiles(replica)
+ write(t, path)
+ // Half as long again as the control needed, and never less than
+ // the database monitor's own interval.
+ wait := streamTook + streamTook/2
+ if wait < dbMonitorInterval+2*time.Second {
+ wait = dbMonitorInterval + 2*time.Second
+ }
+ time.Sleep(wait)
+ if after := ltxFiles(replica); after != before {
+ t.Fatalf("%d replica files after the writes, %d before; want no sync while awake", after, before)
+ }
+ if n := restoreCount(t, replica, filepath.Join(t.TempDir(), "awake.db")); n != 50 {
+ t.Fatalf("the replica restores %d rows while awake; want the 50 from the wake", n)
+ }
+ if err := rep.Close(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if n := restoreCount(t, replica, filepath.Join(t.TempDir(), "asleep.db")); n != 60 {
+ t.Fatalf("the replica restores %d rows after Close; want all 60: hibernation's flush is in full whatever the interval", n)
+ }
+ })
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 7dfc050cd..8735cb634 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -365,6 +365,15 @@ const (
ReplicationBatched = "batched"
// ReplicationDaily syncs at wake and every 24h. Loss window: up to a day.
ReplicationDaily = "daily"
+ // ReplicationHibernateFlush syncs at wake and when the instance goes
+ // to sleep, and at no other time: the platform's baseline promise
+ // (hibernate = durable) and nothing more. Loss window: everything
+ // written since the instance woke, if the host is lost while it is
+ // awake. It is the Free plan's class in the approved pricing design
+ // (no continuous replication, no restore to an earlier moment), and
+ // the same word the entitlement record carries. Nothing sets it from a
+ // plan yet; an operator can.
+ ReplicationHibernateFlush = "hibernate-flush"
)
// ReplicationClasses lists the rungs an operator may set, in order of
@@ -372,7 +381,7 @@ const (
// a route has before anyone chooses, and the way back to it is the CLI's
// clear sentinel, not a name to type.
func ReplicationClasses() []string {
- return []string{ReplicationStream, ReplicationBatched, ReplicationDaily}
+ return []string{ReplicationStream, ReplicationBatched, ReplicationDaily, ReplicationHibernateFlush}
}
// ValidReplication reports whether class is a value this column may hold.
@@ -380,7 +389,7 @@ func ReplicationClasses() []string {
// returns to, exactly as ValidUnitName accepts "".
func ValidReplication(class string) bool {
switch class {
- case ReplicationDefault, ReplicationStream, ReplicationBatched, ReplicationDaily:
+ case ReplicationDefault, ReplicationStream, ReplicationBatched, ReplicationDaily, ReplicationHibernateFlush:
return true
}
return false
diff --git a/internal/registry/replication_test.go b/internal/registry/replication_test.go
index ca459dbb1..aa47b79c1 100644
--- a/internal/registry/replication_test.go
+++ b/internal/registry/replication_test.go
@@ -195,12 +195,12 @@ func TestSetReplicationUnknownHost(t *testing.T) {
// with two independent spellings of the same three strings is how they drift
// into disagreeing about what `daily` means.
func TestValidReplicationVocabulary(t *testing.T) {
- for _, ok := range []string{"", "stream", "batched", "daily"} {
+ for _, ok := range []string{"", "stream", "batched", "daily", "hibernate-flush"} {
if !ValidReplication(ok) {
t.Errorf("ValidReplication(%q) = false", ok)
}
}
- for _, bad := range []string{"dayly", "STREAM", "hourly", "none", "5m"} {
+ for _, bad := range []string{"dayly", "STREAM", "hourly", "none", "5m", "flush", "hibernate_flush"} {
if ValidReplication(bad) {
t.Errorf("ValidReplication(%q) = true", bad)
}
@@ -213,7 +213,8 @@ func TestValidReplicationVocabulary(t *testing.T) {
t.Errorf("ReplicationClasses() offers the default as a rung to type")
}
}
- if len(ReplicationClasses()) != 3 {
- t.Errorf("ReplicationClasses() = %v, want the three rungs", ReplicationClasses())
+ // Four rungs, most durable first; the flush-only class last.
+ if got := ReplicationClasses(); len(got) != 4 || got[0] != ReplicationStream || got[3] != ReplicationHibernateFlush {
+ t.Errorf("ReplicationClasses() = %v, want the four rungs in order of decreasing durability", got)
}
}