Lithair shipped v0.7.0, v0.7.1, and v0.8.0 between 2026-05-15 and 2026-05-19. Each release was driven by friction surfaced from a real application being built on top of the framework — the kind of friction internal sprint planning doesn’t generate. The interesting part isn’t the cadence; it’s what happens to a 0.x framework when its first real consumer can file an issue at 9pm and see it on crates.io before noon.

What it does now (cumulative since v0.6.x)

After this wave, the framework offers a coherent shape for the most common single-binary REST + event-sourced workload:

  • declarative models with auto-generated REST endpoints (existing)
  • session auth gating every auto-generated endpoint, one builder flag (v0.7.0)
  • per-model RAM and .raftlog footprint, JSON + Prometheus (v0.8.0)
  • opt-in auto-compaction with state-survives-restart guarantee (v0.8.0)

What’s still missing — see “Open” below.

Shipped

with_models_require_session(true) (v0.7.0)

  • What — one builder call gates every auto-generated /api/{model} endpoint behind an active session. Otherwise: HTTP 401. Default false, backward-compatible.

    LithairServer::new()
        .with_sessions(SessionManager::new(store))
        .with_models_require_session(true)
        .with_model::<Account>("./data/accounts", "/api/accounts")
        .with_model::<Mail>("./data/mails", "/api/mails")
        .serve()
        .await?
  • Why — the common case for auto-generated CRUD is “logged-in users only”. Before v0.7.0, the only supported path was full RBAC: define a Role enum, implement PermissionChecker, annotate every field, call with_model_full. Four steps to express one bit of information.

  • Choice — builder-level flag, not per-model. Single dispatch point in DeclarativeHttpHandler::handle_request, above the GET/POST/PATCH/DELETE match, after OPTIONS exemption. with_model_full registrations are exempt — they already have RBAC. The two paths coexist.

Bonus latent gap fixed in the same PR: with_model() never actually threaded the registered session store to the auto-generated handler — only with_model_full() did. Without that fix, the new flag would have had nothing to look up. We caught it implementing the gate, not before. That’s the only way these gaps surface.

SessionManager::from_arc + boot-time fail-fast (v0.7.1)

  • What — a split constructor (new(S) vs from_arc(Arc<S>)) so consumers can’t accidentally double-wrap a session store in Arc. The has_valid_session helper now accepts both Arc<SessionManager<S>> and Arc<SessionManager<Arc<S>>> shapes; the boot path detects the double-Arc shape and fails fast with a clear message.
  • Why — surfaced within hours of v0.7.0 publishing. SessionManager::new(Arc::clone(&store)) was the shape that read most naturally — and silently produced a SessionManager<Arc<PersistentSessionStore>> that has_valid_session’s downcast couldn’t resolve, so every authenticated request returned 401. 100% silent breakage.
  • Choice — split the constructor at the type level instead of papering over with documentation. new(store) is the un-Arc’d entry; from_arc(arc_store) is for consumers who already have an Arc. The example in examples/06-auth-sessions was updated to use the safer path. Released same day the issue was filed.

Per-model storage and memory stats (v0.8.0, closes #72)

  • What — two new read-only surfaces:

    • GET /_admin/data/models/{name}/_stats → JSON with item_count, approx_ram_bytes, raftlog_size_bytes, events_since_last_compaction, last_compaction_at.
    • GET /metrics → three new Prometheus gauges per registered model: lithair_model_items{model="..."}, lithair_model_ram_bytes{model="..."}, lithair_model_raftlog_bytes{model="..."}.
  • Why — lithair keeps every registered model in RAM. Without per-model stats, the operator’s only signal for “why is this binary eating 4 GB?” was the process RSS — no breakdown, no per-collection visibility. Capacity planning for production needed actual numbers.

  • Choice — sample-based RAM estimate (up to 16 items serialized, averaged, multiplied by live count) — useful for order-of-magnitude planning, not billing. The estimate is documented as approximate on the ModelStats struct. The Prometheus endpoint parallelizes per-model stats collection with futures::future::join_all after dropping the registry read lock, so it stays sub-second even with many models.

Builder-driven auto-compaction (v0.8.0, closes #69)

  • What — opt-in flag that periodically truncates each registered model’s event log when its event count crosses a threshold:

    use std::time::Duration;
    LithairServer::new()
        .with_model::<Mail>("./data/mails", "/api/mails")
        .with_auto_compaction(10_000, Duration::from_secs(300))
        .serve()
        .await?
  • Why — every full-snapshot mutation (e.g. mark-as-read toggle on a mail) appends a full object dump to .raftlog. With 50 toggles on a 1 KB mail = 50 KB of log for state that didn’t really change. Compaction primitives existed in SnapshotStore, but every consumer had to wire their own cron + monitoring to drive them. The flag was the missing piece.

  • Choice — the compaction logic lives on the ModelHandler::compact() trait method. The server-side spawn loop just calls handler.compact().await when the threshold is hit. The default impl takes a snapshot first, then truncates the event log — atomically, under the event-store write lock. replay_events() now loads the snapshot before events, so a restart after compaction reconstructs full state. Default is disabled — consumers opt in.

The bug we caught (and why we caught it)

The first implementation of auto-compaction was wrong. It called EventStore::truncate_events() directly from the spawn loop, without first writing a state snapshot. In an event-sourced system, the events ARE the state — truncating without a snapshot means the next restart sees an empty log and reconstructs empty storage. Permanent data loss for every model that crossed its threshold.

The bug was caught by automated code review on the PR before merge. The fix was the encapsulation refactor described above (compact() lives on the trait, snapshot-then-truncate is atomic from the caller’s perspective). A regression test (compact_then_reopen_preserves_state) writes 12 items, compacts, drops the handler, reopens against the same data dir, and asserts all 12 items are still visible. A second test verifies post-snapshot events are also replayed correctly.

Without that review pass, a critical data-loss bug would have shipped in v0.8.0. Worth saying out loud.

What we learned

The feedback loop compresses when the consumer is building in parallel. The require-session flag was filed on 2026-05-15, merged 2026-05-16, tagged and on crates.io on 2026-05-17. The double-Arc footgun was filed and fixed the same day, v0.7.1 hours later. The capacity-planning batch produced v0.8.0 the next day. Three releases in four days, every one driven by something a real application tried to do and tripped on. None of these were polished from internal sprint planning; they were pulled into existence by the next ten lines of consumer code refusing to compile, or refusing to work.

Automated review is load-bearing now, not nice-to-have. Across the two v0.8.0 PRs, the automated reviewers flagged: a JSON-injection vector in error responses, a blocking-I/O syscall inside an async fn, a critical data-loss bug, a read-lock held across awaits, a default trait impl that would have been a performance trap, and a sequential per-model stats loop that should have been join_all. Five of those six got fixed before merge. The last one (default trait impl) we declined with rationale. Without the review pass, four of those would have shipped and at least one (data loss) would have caused real damage downstream.

Latent integration gaps surface when you implement the next layer on top. The with_model() / session store wasn't threaded through gap had been there since whenever with_sessions() was added. Nobody noticed until v0.7.0’s session-gate work required actually using the session store from the auto-generated handler. The double-Arc footgun was the same shape — SessionManager::new(Arc::clone(...)) was always broken; nobody had typed it that way before. The point isn’t that the framework had bugs; it’s that the framework had unreached code paths, and a real application is what reaches them.

Open

The consumer-feedback backlog isn’t fully closed:

  • #70 event listener trait — needs an architecture decision before implementation. Deferred.
  • #73 tiered / cold storage — the next big lever for datasets that don’t comfortably fit in RAM. Design doc territory; not v0.8.x.

These will land when they land. The framework’s storage model (everything in RAM, see README.md “Storage and memory model”) is still the same load-bearing constraint — auto-compaction reduces disk pressure on .raftlog, not RAM footprint. If your dataset doesn’t fit in RAM today, lithair still isn’t the right tool. Tiered storage is the path to changing that.

How to try

[dependencies]
lithair-core = "0.8"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

Or full example: examples/06-auth-sessions (sessions + require-session flag) and examples/09-replication (clustering). The new flags:

LithairServer::new()
    .with_sessions(SessionManager::from_arc(store))  // v0.7.1: use from_arc
    .with_models_require_session(true)                // v0.7.0: session gate
    .with_auto_compaction(10_000, Duration::from_secs(300))  // v0.8.0: opt-in
    .with_model::<Mail>("./data/mails", "/api/mails")
    .serve()
    .await?