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>
7 files changed,
+202
−8
docs/superpowers/specs/2026-10-02-flush-only-replication-class.md+29 −0internal/activator/activator.go+14 −2internal/activator/activator_test.go+3 −0internal/console/replicationclass_test.go+23 −0internal/lease/flushonly_test.go+117 −0internal/registry/registry.go+11 −2internal/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) |
| } |
| } |