Skip to content

Latest commit

 

History

History
2769 lines (2117 loc) · 174 KB

File metadata and controls

2769 lines (2117 loc) · 174 KB

Allons — a local-first desktop application framework for Go

Status: design draft — two open items (§19.3), neither blocking phase 1 Target stack: Wails v2 · Go 1.23+ · htmx v4 · html/template (templ optional) · SQLite (WAL) · iroh-ffi Module: allons.dev/allons — runtime packages import as local, localtest Scale target: small org — 10–30 devices, up to a few million ops, tens of GB of attachments, user-owned always-on replicas


0. Thesis and design rules

Allons lets you build a peer-to-peer, offline-capable desktop application as if it were an ordinary server-rendered Go web app. The author writes structs, handlers, templates, and tests. The framework owns the desktop shell, the database, the log, the crypto, and the network.

Five rules constrain every decision below.

  1. The local database is the product. Every read and every write is a local SQLite transaction. Replication is a background process that can be paused, partitioned, or removed entirely without changing what the user sees.
  2. One reducer. Local writes and remote writes take the same path into materialized state. There is no "apply my own change" shortcut, because that is where divergence lives.
  3. Iroh is a socket. It provides authenticated, encrypted, NAT-traversing QUIC. It is not the database, not the log, not the authorization system. Everything durable is ours.
  4. Correctness never depends on timing. Not on datagrams arriving, not on peers being online, not on clocks agreeing, not on a relay existing. Those affect latency and liveness, never convergence.
  5. Generation over reflection. Struct tags are read once at build time by a code generator. The runtime has no reflect-driven object mapper. What the framework does is readable in generated Go you can open and diff.

0.5 The developer contract

Everything after this section is rationale and internals. This is the whole job, grouped by how often you actually do it.

Daily

1. Define a model. A struct in schema/ with a local: tag per field. Three tags carry the first hour — id, lww (last writer wins, the right default for scalars), and tombstone (soft delete) — and allons new scaffolds only those. orset, counter, and blob arrive when you need them; allons schema explain <field> says which fits and what the alternatives cost. Get the choice right before shipping: changing a field's kind afterwards is a versioned migration of everyone's data, and the generator will refuse it.

2. Run allons dev. Regeneration on save, three named windows with --replicas 3, the inspector at /_allons/dev.

3. Mutate through a generated repository. notes.Of(ws).Update(ctx, id, notes.SetTitle(t)) for one model, notes.In(tx) inside ws.Commit for several, notes.With(fn) when a change needs validation or the previous value. Never through SQL — the read handles have no Exec, and that is on purpose.

4. Query through the repository or sqlc. Repository for filter-and-order on one model; a .sql file for joins, aggregates, and search; a projection when derived data is expensive enough to maintain incrementally.

5. Test. webtest for handlers and fragments; localtest.Pair / Partition / Heal / EventuallyEqual for convergence — both using the same repository API the handlers use.

Before shipping

6. Seal, check, package. allons schema seal freezes the draft migration; allons check covers generation freshness, protocol compatibility, tests, and a convergence smoke test; allons package builds the distributable and refuses an unsealed draft. On a shared branch, allons schema rebase after someone else's migration lands (§6.5).

When confused

7. Explain, inspect, doctor. allons explain <request-id> shows authorization, the operation, the reduction, the change event, and which peers acked it. allons inspect --open for the live panels. allons doctor for the toolchain and database integrity.

If a task does not fit one of those steps, the framework is probably making you work around it — that is worth a bug report rather than a workaround.


1. High-level architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│ Wails v2 WebView (WKWebView on macOS)                                       │
│                                                                             │
│   index.html ── htmx v4 (native fetch transport)                            │
│                 └─ hx-get/hx-post → http://wails.localhost/...              │
│   allons-bridge.js                                                          │
│     wails.Events.On("local:notes-changed") ──▶ document.body.dispatchEvent( │
│                                                  CustomEvent("local:...") ) │
│     <section hx-get="/notes" hx-trigger="local:notes-changed from:body">    │
└───────────────┬──────────────────────────────────────────────▲──────────────┘
                │ HTTP (in-process, no socket)                 │ Wails runtime events
                ▼                                              │
┌─────────────────────────────────────────────────────────────────────────────┐
│ Go process                                                                  │
│                                                                             │
│  ┌────────────────────────── local/web ─────────────────────────────────┐   │
│  │ AssetServer http.Handler: chi (default) or any net/http mux          │   │
│  │ embedded static assets · full-vs-fragment render · error fragments   │   │
│  └───────────────┬──────────────────────────────────────────────────────┘   │
│                  │ author's handlers                                        │
│  ┌───────────────▼──────────────────────────────────────────────────────┐   │
│  │ Application layer (author-written + generated)                       │   │
│  │   commands: notes.Update(...)      queries: notes.List(...)          │   │
│  │   validation                        typed repositories               │   │
│  └───────────────┬──────────────────────────────────────────────────────┘   │
│                  │                                                          │
│  ┌───────────────▼───────────── local (core) ───────────────────────────┐   │
│  │                                                                      │   │
│  │   ┌──────────┐   ┌───────────┐   ┌──────────┐   ┌─────────────────┐  │   │
│  │   │ commit   │──▶│  reducer  │──▶│  store   │   │  auth: caps,    │  │   │
│  │   │ pipeline │   │  (crdt)   │   │ (sqlite) │   │  members, epochs│  │   │
│  │   └────┬─────┘   └─────▲─────┘   └────┬─────┘   └────────▲────────┘  │   │
│  │        │ envelope      │ verified ops │  WAL             │           │   │
│  │        ▼               │              ▼                  │           │   │
│  │   ┌──────────┐   ┌─────┴─────┐   ┌──────────┐   ┌────────┴────────┐  │   │
│  │   │  oplog   │   │  intake   │   │  events  │   │  blob (BLAKE3   │  │   │
│  │   │ sign/enc │   │ verify/az │   │   bus    │   │  CAS on disk)   │  │   │
│  │   └────┬─────┘   └─────▲─────┘   └────┬─────┘   └────────▲────────┘  │   │
│  └────────┼───────────────┼──────────────┼──────────────────┼───────────┘   │
│           │               │              │ coalesced        │               │
│  ┌────────▼───────────────┴──────────────┴──────────────────┴───────────┐   │
│  │ local/sync — replication engine                                      │   │
│  │   per-peer session state machine · WANT/OPS/ACK · anti-entropy timer │   │
│  │   backpressure · retry+jitter · strike limits · blob fetch queue     │   │
│  └────────────────────────────┬─────────────────────────────────────────┘   │
│                               │ wire.Frame (protobuf, length-prefixed)      │
│  ┌────────────────────────────▼─────────────────────────────────────────┐   │
│  │ local/transport — the ONLY package that imports iroh bindings        │   │
│  │   Endpoint lifecycle · ALPN dev.example.app/sync/1 · dial/accept     │   │
│  │   bi-streams (control, blobs) · uni-streams (op batches) · datagrams │   │
│  │   tickets · address lookup · path/migration events · shutdown        │   │
│  └────────────────────────────┬─────────────────────────────────────────┘   │
└───────────────────────────────┼─────────────────────────────────────────────┘
                                │ CGO
                    ┌───────────▼────────────┐
                    │ iroh-ffi (Rust, static)│  QUIC · hole punching · relay
                    └───────────┬────────────┘
                                │
        ╔═══════════════════════▼════════════════════════╗
        ║  peers: other desktops, optional always-on     ║
        ║  replica (same protocol, no special authority) ║
        ╚════════════════════════════════════════════════╝

Filesystem layout (per app, in os.UserConfigDir) — one DB per workspace, shared endpoint:
  keys/        endpoint.key user.key   protected 0600, OS-keychain wrapped
  blobs/       ab/cd/<blake3-hex>      global CAS, per-workspace refcounts summed
  blobs/tmp/   <random>                staging; swept at startup
  logs/        app.jsonl               slog output, rotated
  workspaces/
    <ws-id>/   app.db app.db-wal       authoritative state for ONE workspace
               keys/replica.key        this device's signing key in that workspace

Two things cross the process boundary, and nothing else. HTTP requests come in from the WebView; Wails runtime events go out. No WebSockets, no SSE, no JS-callable Go bindings for domain logic. The UI is HTML over HTTP; the only push channel is an event name plus a small JSON payload.


2. Package and module boundaries

allons.dev/allons
├── local/                  # public façade. App, Config, ID, Schema, lifecycle.
│   ├── store/              # sqlite open/pragma/migrate, Tx helpers, integrity, backup
│   ├── crdt/               # LWW, ORSet, Counter, Tombstone primitives; merge fns
│   ├── oplog/              # Envelope, canonical bytes, sign, verify, append, scan
│   ├── auth/               # identities, capabilities, membership, invites, epochs
│   ├── blob/               # BLAKE3 CAS, staged write, verify, refcount, GC
│   ├── wire/               # protobuf messages, framing, size limits, codec fuzz surface
│   ├── transport/          # iroh endpoint, dial/accept, streams. ← only iroh importer
│   ├── sync/               # session state machine, anti-entropy, scheduling
│   ├── events/             # change bus, coalescing, Wails emitter
│   ├── web/                # render, htmx helpers, asset server, bridge JS
│   └── diag/               # slog setup, counters, status model, bundle export
├── localtest/              # simulation harness: replicas, faults, EventuallyEqual
├── cmd/allons/             # CLI: new dev generate inspect doctor test build …
└── internal/
    ├── codegen/            # tag parser → typed repos, reducers, codecs, migrations
    ├── irohffi/            # vendored native libs + cgo directives + checksums
    └── proto/              # generated protobuf Go

Dependency rules (enforced by go vet-style import lint in CI)

Package May import Must not import
crdt, wire, oplog stdlib, proto, blake3, ed25519 store, sync, transport
store crdt, oplog, driver sync, transport, web
auth store, oplog, crdt transport, sync
transport wire, iroh bindings store, sync, local
sync store, oplog, auth, wire, blob, transport interfaces only web, iroh bindings
web local, events sync, transport, store
local everything below it

The critical line is sync → transport. sync depends on:

package transport

type Transport interface {
    EndpointID() EndpointID
    Ticket(context.Context) (Ticket, error)
    Dial(ctx context.Context, addr Addr) (Conn, error)
    Accept(ctx context.Context) (Conn, error)
    Close(context.Context) error
}

type Conn interface {
    Remote() EndpointID
    OpenBi(ctx context.Context) (Stream, error)
    OpenUni(ctx context.Context) (io.WriteCloser, error)
    AcceptBi(ctx context.Context) (Stream, error)
    AcceptUni(ctx context.Context) (io.ReadCloser, error)
    SendDatagram(b []byte) error          // best effort, may return ErrTooLarge
    RecvDatagram(ctx context.Context) ([]byte, error)
    Path() PathInfo                        // relay vs direct, RTT, migration count
    CloseWith(code uint32, reason string) error
}

localtest supplies an in-memory Transport with a deterministic scheduler. The entire replication engine is testable without CGO, without a network, and without iroh. That is what makes the fault matrix in §13 affordable.


3. Public Go API

3.1 Application lifecycle

package local

type Config struct {
    AppID       string        // reverse-DNS, e.g. "dev.example.notes"
    ALPN        string        // default: AppID + "/sync/1"
    DataDir     string        // default: os.UserConfigDir()/AppID
    Schema      Schema        // generated: local.Schema from schema_gen.go
    Assets      fs.FS         // embedded static files
    Logger      *slog.Logger  // default: JSON to logs/app.jsonl + stderr

    Routes      func(*App) http.Handler  // built after the App exists, so handlers
                              // can hold it; replaces a prebuilt http.Handler
    Bootstrap   Bootstrap     // first-launch behavior; default AutoWorkspace("Personal")
    Policy      auth.Policy   // default: auth.DefaultPolicy (§3.3)
    Limits      Limits        // see §16.1; zero value = safe defaults
    Sync        SyncConfig    // relay config, anti-entropy interval, autostart
    AtRest      AtRestConfig  // optional SQLCipher-style page encryption / OS keychain
}

// App is a container: it owns the iroh endpoint, the blob CAS, the event bus,
// and the HTTP handler. It owns no domain state.
type App struct{ /* unexported */ }

func Open(ctx context.Context, cfg Config) (*App, error)  // loads keys, opens CAS
func (a *App) Start(ctx context.Context) error            // starts endpoint + accept loop
func (a *App) Handler() http.Handler                      // give this to Wails AssetServer
func (a *App) Close(ctx context.Context) error            // drain, checkpoint, close all

func (a *App) Workspaces() []WorkspaceRef
func (a *App) CreateWorkspace(ctx context.Context, name string) (*Workspace, error)
func (a *App) OpenWorkspace(ctx context.Context, id WorkspaceID) (*Workspace, error)
func (a *App) JoinWorkspace(ctx context.Context, invite auth.Invite) (*Workspace, error)
func (a *App) LeaveWorkspace(ctx context.Context, id WorkspaceID) error  // unlinks the DB file
func (a *App) Active() (*Workspace, bool)  // false on first launch — see §11.1
func (a *App) SetActive(ctx context.Context, id WorkspaceID) error  // opens it,
                                           // re-points the event bridge (§11.3),
                                           // errors on unknown or unopenable
func (a *App) Events() *events.Bus
func (a *App) Status() diag.Status         // §14
func (a *App) Identity() auth.Identity

// Workspace owns one SQLite database, one op log, one sync engine, one replica key.
type Workspace struct{ /* unexported */ }

func (w *Workspace) ID() WorkspaceID
func (w *Workspace) Read() ReadDB          // query-only, enforced — see §3.2a
func (w *Workspace) Sync() *sync.Engine
func (w *Workspace) Status() diag.WorkspaceStatus
func (w *Workspace) Close(context.Context) error

// Membership — the author-facing half of §9.
func (w *Workspace) Invite(ctx context.Context, o auth.InviteOptions) (auth.Invite, error)
func (w *Workspace) RevokeInvite(ctx context.Context, id auth.InviteID) error
func (w *Workspace) Invites(ctx context.Context) ([]auth.InviteRef, error)
func (w *Workspace) Members(ctx context.Context) ([]auth.Member, error)
func (w *Workspace) RevokeDevice(ctx context.Context, r ReplicaID, reason string) error

// Attachments — staged write (§8.5), returns after hash + fsync, before the commit
// that references it.
func (w *Workspace) PutBlob(ctx context.Context, r io.Reader, mime string) (Blob, error)
func (w *Workspace) Pin(ctx context.Context, scope PinScope) (*PinProgress, error)
type auth.InviteOptions struct {
    Role     Role          // default RoleReader
    Expires  time.Duration // default 24h
    MaxUses  int           // advisory only, see §9.5
    Label    string        // shown in `allons inspect invites`
}

Open never touches the network. Start is separable so tests, the CLI, and --offline mode share one code path. Workspaces open lazily — migrations run per workspace on first open, so startup cost is proportional to what the user is actually looking at rather than to how many workspaces they belong to.

3.2 Commands: the transaction contract

// Commit runs fn inside ONE sqlite transaction. Ops emitted by fn are signed,
// appended to the log, and reduced into materialized tables before commit.
// Returns after fsync (synchronous=NORMAL in WAL: after WAL write).
func (w *Workspace) Commit(ctx context.Context, fn func(tx *Tx) error) error

type Tx struct{ /* unexported */ }

func (tx *Tx) Context() context.Context
func (tx *Tx) Read() ReadTx        // query-only; see §3.2a
func (tx *Tx) Actor() auth.Actor   // who; enforced against workspace policy
func (tx *Tx) Notify(topic string, e ...EntityRef) // change event, published post-commit
func (tx *Tx) Emit(kind string, payload proto.Message) (OpID, error) // escape hatch

Multi-model commands are the normal case, not an escape hatch. Every generated repository has two constructors: Of(ws) opens its own transaction, and In(tx) joins the caller's. The first is sugar for the second, which keeps one code path:

// Self-contained — opens, commits, returns.
note, err := notes.Of(ws).Update(ctx, id, notes.SetTitle(title))

// Atomic across models, with validation that reads committed-in-tx state.
err := ws.Commit(ctx, func(tx *local.Tx) error {
    note, err := notes.In(tx).Update(id,
        notes.SetTitle(title),
        notes.With(func(u *notes.NoteUpdate) error {
            if u.Current().Deleted() {
                return ErrNoteDeleted      // aborts the transaction, no ops emitted
            }
            return nil
        }),
    )
    if err != nil { return err }

    return tags.In(tx).Ensure(note.Tags()...)
})

Two details that make this work:

  • The mutation callback returns error. Cross-field validation, state-transition checks, and reusable domain rules live inside the closure and abort the whole transaction. u.Current() exposes the pre-mutation entity for exactly that.
  • In(tx) methods take no ctx. The transaction already carries one, and accepting a second invites passing a different context than the transaction is running under. tx.Context() is there when a call downstream needs it.

Authors should rarely reach tx.Emit — it exists for op kinds the generator does not produce.

3.2a Reads are query-only, and it is enforced

// ReadDB and ReadTx expose queries and nothing else. There is no Exec.
type ReadDB interface {
    QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error)
    QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row
}
type ReadTx interface{ ReadDB }

func (w *Workspace) Read() ReadDB    // sqlc queries run here

A write outside the op log changes local state without producing an operation: the replica diverges silently, no error is raised, and nothing detects it short of a fingerprint mismatch in testing. It is the most damaging single line an author can write, so it is blocked three ways:

  1. The interface has no Execws.Read().ExecContext(...) does not compile.
  2. The read pool opens connections with PRAGMA query_only=ON — a write reaching them through any path fails at the driver.
  3. The generator rejects non-SELECT sqlc queries at build time (§5).

Unrestricted handles stay internal to local/store. Nothing in the public API hands out a write-capable *sql.DB.

3.3 Authorization policy

package auth

type Actor struct {
    UserID    UserID
    ReplicaID ReplicaID
    Role      Role     // RoleReader | RoleEditor | RoleAdmin | RoleOwner
    Workspace WorkspaceID
}

// Policy is consulted for BOTH local commands and inbound remote operations.
// It must be a pure function of (actor, op, membership snapshot at op.HLC).
type Policy interface {
    Allow(a Actor, op oplog.Header, m MembershipView) error
}

type MembershipView interface {
    RoleAt(ReplicaID, HLC) (Role, bool)
    RevokedAt(ReplicaID, HLC) bool
    EpochFloor() HLC
}

Default policy: readers may emit nothing; editors may emit any kind not prefixed auth.; admins may emit auth.* except auth.transfer_owner; owner only for that. Authors override per-kind or per-workspace.

3.4 Rendering

package web

// Component is structurally identical to templ.Component, so templ components
// satisfy it with no import of templ anywhere in the framework.
type Component interface {
    Render(ctx context.Context, w io.Writer) error
}

func HTML(t *template.Template, name string, data any) Component

// ── responses: every one writes and returns error ──────────────────────────

// Render picks full page vs fragment from htmx v4's HX-Request-Type header.
// Full → layout(shell, fragment). Partial → fragment only.
func Render(w http.ResponseWriter, r *http.Request, layout Layout, frag Component) error

// Fragment always renders just the component (hx-swap targets, OOB).
func Fragment(w http.ResponseWriter, r *http.Request, c Component) error

// Redirect uses HX-Redirect for partial requests, 303 otherwise.
func Redirect(w http.ResponseWriter, r *http.Request, to string) error

// NoContent returns 204 and lets this request's change event drive the refresh
// instead of rendering here (§11.3).
func NoContent(w http.ResponseWriter, r *http.Request) error

func OOB(c Component) Component                   // wraps with hx-swap-oob="true"
func Retarget(w http.ResponseWriter, sel string)  // HX-Retarget
func IsPartial(r *http.Request) bool

// ── errors: values, not writes ─────────────────────────────────────────────

// Invalid returns an error carrying a component. web.Handler renders it as 422
// with the fragment body. Being a value rather than a write means a validation
// failure from three layers down can `return err` and still render correctly.
func Invalid(c Component) error

// Handler adapts an error-returning func to http.Handler:
//   ErrNotFound        → 404
//   ErrForbidden       → 403 fragment
//   Invalid / ValidationError → 422 fragment  (well-formed request, bad values)
//   MalformedError     → 400                  (unparseable body, bad multipart)
//   anything else      → 500 + slog with the request ID, no detail to the client
func Handler(fn func(http.ResponseWriter, *http.Request) error) http.Handler

func ParamID(r *http.Request, name string) (local.ID, error)

// Bind is the form path handlers should use. It classifies for you: a
// ValidationError is wrapped with onInvalid into a 422 fragment, while a
// malformed request passes through as a 400. No errors.As in every handler.
func Bind[T any](r *http.Request, onInvalid func(error) Component) (T, error)

// Form is the unclassified primitive, for handlers that want to branch themselves.
func Form[T any](r *http.Request) (T, error)

// File enforces Limits.MaxUploadBytes; r.FormFile does not.
// Returns ErrNoFile when the field is absent, so "optional" is distinguishable
// from "too large", "malformed multipart", and I/O failure.
func File(r *http.Request, name string) (multipart.File, *multipart.FileHeader, error)

var ErrNoFile = errors.New("web: no file in field")

Two conventions make this consistent. Responses write and return error — never error-free, so a handler is always return web.Something(...). Validation is an error value rather than a write, so it composes: a service three layers down can return web.Invalid(...) and the handler's bare return err renders it correctly.

ParamID returns an error rather than degrading a malformed ID into a mysterious 404. One extra line, much better failure.

Form[T] contract

Deliberately small, because form binding is where "helpful" frameworks become unpredictable.

Aspect Behavior
Types string, bool, int/int64, float64, time.Time (RFC3339 or date), local.ID, []T of those, and *T of the scalars
Checkboxes absent → false; any present value → true
Missing vs empty value types always yield the zero value; pointers distinguish, following how HTML actually posts forms
Repeated values bind to a slice field; binding a repeat to a scalar is an error
Unknown fields ignored — browsers and extensions add fields, and strictness here breaks real pages
File uploads not handled by Bind — use web.File, which enforces MaxUploadBytes; r.FormFile does not. Blob writes stay visible in the handler
Body limit Limits.MaxFormBytes (default 8 MiB), exceeded → MalformedError → 400
Validation tags required, min, max, len, oneof; anything richer is a Validate() error method on the form struct
Failure shape ValidationError with stable field names matching the form: tags, so a fragment can mark individual inputs

Pointer semantics, with the literal requests, because "empty" in an HTML form is not the same as "absent":

Field Request Result
Note *string (field not sent) nil
Note *string note= pointer to "" — the user cleared it
Due *time.Time due= nil — an empty date input is absent, not malformed
Count *int count= nil
Count *int count=abc ValidationError{Field: "count", Code: "not_a_number"}

The rule: an empty string means absent for every pointer type except *string, where it means present and empty. Browsers always post empty inputs, so any other rule would make every optional numeric field a validation error.

Multipart uses the same binder. Bind calls ParseMultipartForm when the content type is multipart and binds the non-file fields; web.File reads a file part from the same already-parsed form, so the body limit is applied once. There is one flow, not two:

form, err := web.Bind[NewNoteForm](r, views.NoteErrors)   // text fields
if err != nil { return err }

f, hdr, err := web.File(r, "cover")                        // file part

Malformed request → 400. Well-formed request with bad values → 422 with the fragment. Those are different failures and htmx should treat them differently.

Validation errors are presentation-neutral. ValidationError and its FieldError{Field, Code, Params} live in local and carry stable field codes — no HTML, no components. Domain code three layers down returns one without importing views; the mapping to a fragment happens once, at the handler boundary, in Bind's onInvalid or in web.Handler's configured renderer. web.Invalid(component) remains the shortcut for handlers that already hold a component.

Form[T] uses reflection, and that is deliberate: the no-reflection rule governs the replication path, not the HTTP edge. Nothing in web participates in convergence, so a struct-tag form binder is ordinary Go rather than a violation. Reducers, codecs, and the fingerprint stay generated.

Every helper is optional. Config.Routes returns any http.Handler, and dropping to a bare func(w, r) is always available.

3.5 Events

package events

type Bus struct{ /* … */ }

func (b *Bus) Publish(topic string, detail any)     // coalesced, see §11.3
func (b *Bus) Subscribe(topic string) (<-chan Event, func())

// Framework topics, always present:
//   local:sync-status   local:peer-changed   local:workspace-changed
//   local:conflict      local:blob-progress  local:upgrade-required
// Generated per-model topics: local:notes-changed, local:tags-changed, …

3.6 Sync control

package sync

func (e *Engine) AddPeer(ctx, ticket transport.Ticket) error
func (e *Engine) RetirePeer(ctx, ReplicaID) error       // emits auth.retire op
func (e *Engine) Pause() ; func (e *Engine) Resume()
func (e *Engine) SyncNow(ctx) error                     // manual anti-entropy round
func (e *Engine) Heads(ctx) (map[ReplicaID]uint64, error)
func (e *Engine) Peers() []PeerStatus

3.7 Wails adapter

Open/Start/Close stay public for tests, the CLI, and headless allons serve. But every desktop application repeats the same four-step dance — bind the Wails context, start after startup, close on shutdown, own cancellation correctly — and getting the context ownership subtly wrong is a real failure mode (events emitted against a dead context are silently dropped).

package wailsapp

// Run owns the whole lifecycle: Open, wails.Run, event binding on startup,
// Start, graceful Close on shutdown, and a bounded shutdown deadline.
func Run(cfg local.Config, opts *options.App) error
func main() {
    must(wailsapp.Run(local.Config{
        AppID:  "dev.example.notes",
        Schema: gen.Schema(),
        Assets: assets,
        Routes: func(app *local.App) http.Handler {
            return routes(&Handlers{App: app})
        },
    }, &options.App{Title: "Notes"}))
}

Config.Routes is a factory rather than a prebuilt http.Handler because handlers need the *AppCreateWorkspace, JoinWorkspace, and SetActive all live on it, and so does whatever the application wants to inject. Open constructs the App, then calls the factory once. This keeps the short lifecycle without the abrupt drop to the manual Open/Start/Close path the moment a handler needs a dependency.

The same field works in tests and headless allons serve, so there is one wiring shape rather than one per entry point.


4. Example application: Notes

4.1 Schema (hand-written, ~20 lines)

// schema/note.go
package schema

import "allons.dev/allons/local"

//go:generate allons generate ./...

// The `local:` tags are the signal — no directive needed.
// Defaults: table "notes", topic "local:notes-changed".
// Override with: +allons:model table=… topic=…
type Note struct {
    ID       local.ID  `local:"id"`
    Title    string    `local:"lww"`
    Body     string    `local:"lww"`
    Tags     []string  `local:"orset"`
    Pinned   bool      `local:"lww"`
    Views    int64     `local:"counter"`
    Cover    local.Blob `local:"blob"`          // attachment reference
    Deleted  bool      `local:"tombstone"`

    CreatedAt local.HLC `local:"created"`       // set once, from op HLC
    UpdatedAt local.HLC `local:"updated"`       // max of applied op HLCs
}

// +allons:index notes(pinned DESC, updated_at DESC) where deleted = 0
// +allons:index notes_fts(title, body) using fts5

4.2 Command handler (hand-written)

// app/notes.go
import (
    "myapp/gen/db"     // sqlc output
    "myapp/gen/notes"  // typed repository
)

type UpdateNoteForm struct {
    Title     string `form:"title" validate:"required"`
    Body      string `form:"body"`
    Important bool   `form:"important"`
}

func (h *Handlers) UpdateNote(w http.ResponseWriter, r *http.Request) error {
    ws := local.WorkspaceFrom(r.Context())

    id, err := web.ParamID(r, "id")
    if err != nil { return err }                       // → 400, not a mystery 404

    // Bind classifies: validation → 422 fragment, malformed → 400.
    form, err := web.Bind[UpdateNoteForm](r, views.NoteErrors)
    if err != nil { return err }

    note, err := notes.Of(ws).Update(r.Context(), id,
        notes.SetTitle(form.Title),
        notes.SetBody(form.Body),
        notes.When(form.Important, notes.AddTags("important")),
    )
    if err != nil {
        return err        // ErrNotFound / ErrForbidden / 500, mapped by web.Handler
    }
    return web.Fragment(w, r, views.NoteCard(note))
}

func (h *Handlers) ListNotes(w http.ResponseWriter, r *http.Request) error {
    ws, ctx := local.WorkspaceFrom(r.Context()), r.Context()

    // Filter/order one model: the generated repository.
    list, err := notes.Of(ws).Query().
        Where(notes.Deleted.Eq(false)).
        OrderBy(notes.Pinned.Desc(), notes.UpdatedAt.Desc()).
        Limit(200).All(ctx)
    if err != nil { return err }

    // Joins, aggregates, search: sqlc against a projection (§7.5).
    facets, err := db.New(ws.Read()).TopTags(ctx, ws.ID())
    if err != nil { return err }

    return web.Render(w, r, views.Shell, views.NoteList(list, facets))
}

Update returns after commit, so the HTML you render is durable state.

Persistence is never optimistic; presentation still can be. A text field may echo keystrokes locally, a toggle may flip before its round-trip returns, a drag may animate immediately — provided it reconciles with committed state when the fragment arrives. What the framework removes is the pending write: there is no queue of changes that a server might later reject, because a commit that returns has already fsynced. Optimistic UI here is a rendering choice with a millisecond horizon, not a correctness gamble.

4.3 Template (hand-written, html/template)

{{/* views/notes.html */}}
{{define "note-list"}}
<section id="notes"
         hx-get="/notes"
         hx-trigger="local:notes-changed from:body delay:80ms"
         hx-swap="outerHTML">
  {{range .Notes}}{{template "note-card" .}}{{end}}
</section>
{{end}}

{{define "note-card"}}
<article id="note-{{.ID}}" class="note">
  <form hx-put="/notes/{{.ID}}" hx-target="#note-{{.ID}}" hx-swap="outerHTML">
    <input name="title" value="{{.Title}}">
    <textarea name="body">{{.Body}}</textarea>
    <button>Save</button>
  </form>
</article>
{{end}}

4.4 Wiring (hand-written, ~15 lines)

//go:embed all:assets
var assets embed.FS

func main() {
    must(wailsapp.Run(
        local.Config{
            AppID:  "dev.example.notes",
            Schema: gen.Schema(),             // generated registry
            Assets: assets,
            Routes: func(app *local.App) http.Handler {
                return routes(&Handlers{App: app})
            },
        },
        &options.App{Title: "Notes"},
    ))
}

func routes(h *Handlers) http.Handler {
    r := chi.NewRouter()
    r.Method("GET",    "/notes",      web.Handler(h.ListNotes))
    r.Method("POST",   "/notes",      web.Handler(h.CreateNote))
    r.Method("PUT",    "/notes/{id}", web.Handler(h.UpdateNote))
    r.Method("DELETE", "/notes/{id}", web.Handler(h.DeleteNote))
    r.Method("GET",    "/sync",       web.Handler(h.SyncPanel))
    r.Method("POST",   "/invites",    web.Handler(h.CreateInvite))
    return r
}

That is the entire application surface. No goroutine management, no channel plumbing, no protocol code.

Routes are unprefixed because they operate on the active workspace (§11.1) — the one the window is showing and the event bridge is filtering on. A workspace switcher uses the explicit /w/{workspace}/... form; everything else does not pay for multi-workspace it isn't using.

4.5 The complete vertical slice

The design's acceptance test: every line needed to create a workspace, create a note with an attachment, invite a second device, edit concurrently, and render both results. Anything this example needs belongs in the public API.

This slice is a compile-checked contract, not prose. It lives in examples/notes/, is built and vetted by CI, and the code blocks below are extracted from it. A signature that drifts between the API listing and the example lands squarely on the daily experience, so the document is made unable to disagree with the compiler.

Create a workspace (first run, or the "+" button):

func (h *Handlers) CreateWorkspace(w http.ResponseWriter, r *http.Request) error {
    ws, err := h.App.CreateWorkspace(r.Context(), r.FormValue("name"))
    if err != nil { return err }
    if err := h.App.SetActive(r.Context(), ws.ID()); err != nil { return err }
    return web.Redirect(w, r, "/notes")      // routes + event bridge follow
}

Create a note with an attachment. PutBlob performs the staged write from §8.5 — hash, verify, fsync, atomic rename — and returns before the commit that references it, so a crash between them leaves an orphan file the startup sweeper collects, never a dangling row:

func (h *Handlers) CreateNote(w http.ResponseWriter, r *http.Request) error {
    ws := local.WorkspaceFrom(r.Context())

    form, err := web.Bind[NewNoteForm](r, views.NoteErrors)   // text fields
    if err != nil { return err }

    var cover local.Blob
    f, hdr, err := web.File(r, "cover")                       // file part
    switch {
    case errors.Is(err, web.ErrNoFile):     // optional — not an error
    case err != nil:
        return err                          // too large / malformed / I/O
    default:
        defer f.Close()
        if cover, err = ws.PutBlob(r.Context(), f, hdr.Header.Get("Content-Type")); err != nil {
            return err
        }
    }

    note, err := notes.Of(ws).Create(r.Context(),
        notes.SetTitle(form.Title),
        notes.SetCover(cover),
    )
    if err != nil { return err }
    return web.Fragment(w, r, views.NoteCard(note))
}

Invite a second device:

func (h *Handlers) CreateInvite(w http.ResponseWriter, r *http.Request) error {
    ws := local.WorkspaceFrom(r.Context())
    inv, err := ws.Invite(r.Context(), auth.InviteOptions{
        Role:    auth.RoleEditor,
        Expires: 24 * time.Hour,
        Label:   "Dana's laptop",
    })
    if err != nil { return err }
    return web.Fragment(w, r, views.InviteLink(inv.URL()))  // allons-invite:v1:…
}

Accept it on the other device — the invite carries both the capability and the iroh ticket (§9.5), so this is the whole pairing flow:

func (h *Handlers) JoinWorkspace(w http.ResponseWriter, r *http.Request) error {
    inv, err := auth.ParseInvite(r.FormValue("invite"))
    if err != nil { return web.Invalid(views.BadInvite(err)) }

    ws, err := h.App.JoinWorkspace(r.Context(), inv)
    if err != nil { return err }
    if err := h.App.SetActive(r.Context(), ws.ID()); err != nil { return err }
    return web.Redirect(w, r, "/notes")
}

Edit concurrently and render both. No application code — the fragment declared in §4.3 already refreshes on local:notes-changed, which fires when a remote operation lands (§11.3). Both devices converge by LWW with deterministic tie-breaking (§7.3), and both render the same winner.

What this slice does not require you to write: any peer discovery, any connection handling, any conflict resolution, any polling, any WebSocket, any migration, any manual event wiring. Six handlers and a template.


5. Generated-code boundaries

5.1 Project layout

allons new scaffolds only what the smallest credible app needs — schema/, app/, views/, main.go. queries/, projections/, and effects/ appear the first time you write one; the generator locates code by tags and interfaces, not by path, so an empty directory teaches nothing and an absent one costs nothing.

One invariant: hand-written and generated code never share a directory. Everything the generator produces lives under gen/, which means rm -rf gen && allons generate is safe by construction, one .gitattributes line covers the lot, and no filename prefix convention has to be remembered.

notes/
├── go.mod
├── .gitattributes                  gen/** linguist-generated=true -merge
├── main.go
│
├── schema/                 HAND    the data model
│   ├── note.go                       struct, indexes, custom reducers
│   └── tag.go
├── .gitignore                      migrations/*_draft.sql (never committed)
├── queries/                HAND    sqlc inputs
│   ├── facets.sql
│   └── search.sql
├── projections/            HAND    Projection implementations (§7.5)   ← when used
│   └── tag_facets.go
├── effects/                HAND    Effect implementations (§7.6)       ← when used
│   └── invite_email.go
├── app/                    HAND    handlers
│   └── notes.go
├── views/                  HAND    templates
├── migrations/             FROZEN  generator-authored, reviewed, never deleted
│   ├── 0001_init.sql
│   └── lock.json
│
└── gen/                    GENERATED — delete the directory, regenerate
    ├── notes/                package notes  →  notes.Of(ws).Update(…)
    ├── tags/                 package tags
    ├── db/                   package db — sqlc output from queries/
    ├── proto/
    ├── registry.go           package gen — Schema(), reducers, projections, effects
    ├── fingerprint.go
    ├── schema.sql            consolidated DDL, sqlc's only input
    └── ops.proto

The author's struct in schema/ is a declaration only — it is never instantiated at runtime. The generated package emits a distinct read type with unexported fields and value-receiver accessors:

package notes

type Note struct{ /* unexported */ }

func (n Note) ID() local.ID
func (n Note) Title() string
func (n Note) Tags() []string        // materialized from notes_tags
func (n Note) Views() int64          // summed from notes_views
func (n Note) Cover() local.Blob
func (n Note) UpdatedAt() local.HLC

// Two constructors: one opens its own transaction, one joins the caller's.
func Of(ws *local.Workspace) *Repo
func In(tx *local.Tx) *TxRepo

// Mutations take options. Simple changes stay one line; With() is the advanced
// form when validation or the pre-mutation entity is needed.
func (r *Repo)   Get(ctx context.Context, id local.ID) (Note, error)
func (r *Repo)   Create(ctx context.Context, opts ...Option) (Note, error)
func (r *Repo)   Update(ctx context.Context, id local.ID, opts ...Option) (Note, error)
func (r *Repo)   Query() *Query

func (r *TxRepo) Get(id local.ID) (Note, error)             // tx carries ctx
func (r *TxRepo) Create(opts ...Option) (Note, error)
func (r *TxRepo) Update(id local.ID, opts ...Option) (Note, error)

// Generated per field, one verb per CRDT kind.
func SetTitle(v string) Option
func AddTags(v ...string) Option
func RemoveTags(v ...string) Option
func IncViews(n int64) Option
func SetCover(b local.Blob) Option
func Delete() Option

// Combinators, so conditional edits need no XxxIf variant per field.
func When(cond bool, opts ...Option) Option

// With is just another Option, so the two forms mix freely.
func With(fn func(*NoteUpdate) error) Option
func (u *NoteUpdate) Current() Note        // pre-mutation entity, for validation
// The 80% case — no closure, no `return nil`.
note, err := notes.Of(ws).Create(ctx, notes.SetTitle(title), notes.AddTags("inbox"))

// The advanced case, mixed with plain options.
note, err := notes.Of(ws).Update(ctx, id,
    notes.SetTitle(title),
    notes.With(func(u *notes.NoteUpdate) error {
        if u.Current().Deleted() {
            return ErrNoteDeleted           // aborts the transaction
        }
        u.Tags.Add(deriveTag(u.Current()))
        return nil
    }),
)

Queries hang off generated field namespaces, so predicate names are mechanical rather than invented and editor completion finds them:

func (q *Query) Where(p ...Predicate) *Query     // AND; Or/Not are combinators
func (q *Query) OrderBy(o ...Order) *Query
func (q *Query) Limit(n int) *Query
func (q *Query) All(ctx context.Context) ([]Note, error)
func (q *Query) One(ctx context.Context) (Note, error)
func (q *Query) Count(ctx context.Context) (int64, error)

// Pagination is explicit, so the common call stays two-valued.
func (q *Query) Page(ctx context.Context, c Cursor, n int) (local.Page[Note], error)
// Page[T] = { Items []T; Next Cursor; More bool }

var (
    Title     local.StringField  // Eq NotEq In Contains HasPrefix Asc Desc
    Pinned    local.BoolField    // Eq Asc Desc
    Views     local.NumField     // Eq NotEq In Lt Lte Gt Gte Between Asc Desc
    UpdatedAt local.HLCField     // Eq Before After Between Asc Desc
    Tags      local.SetField     // Has HasAny HasAll Empty
    Deleted   local.BoolField
)

func Or(p ...Predicate) Predicate
func Not(p Predicate) Predicate
notes.Of(ws).Query().
    Where(notes.Deleted.Eq(false)).
    Where(notes.Or(notes.Tags.Has("urgent"), notes.Pinned.Eq(true))).
    Where(notes.UpdatedAt.After(since)).
    OrderBy(notes.Pinned.Desc(), notes.UpdatedAt.Desc()).
    Page(ctx, cursor, 200)

Scope, stated so nobody has to guess. The builder covers one model: field predicates, boolean combinators, ordering, keyset pagination. It does not cover joins, aggregates, grouping, subqueries, or relation membership — those go to a .sql file and sqlc (§7.5), which is a one-file step rather than a wall.

When an ordering has no supporting index SQLite still answers it. Dev builds run EXPLAIN QUERY PLAN over composed queries and log one line naming the scan and the index that would fix it; release builds skip the check.

Unexported fields are the point. Were the schema struct re-exported directly, note.Title = "x" would compile and do nothing durable — an ordinary-looking assignment that silently lies, surfacing hours later as "my change vanished". Here it does not compile, so the read/write asymmetry is structural rather than documented, and mutation has exactly one spelling: NoteUpdate inside Create/Update.

Templates are unaffected — {{.Title}} calls the method, so html/template and templ both work unchanged.

migrations/ is the honest exception — generator-authored but reviewed, frozen once shipped, and never deleted.

One import rule this layout creates, checked by the generator with a clear error rather than left to go build's cycle diagnostics: projections/ and effects/ may import gen/<model>, but never gen itself. The registry imports them to register them, so the reverse direction cycles.

5.2 What is generated

allons generate reads +allons: comment directives and local:"…" struct tags and emits:

Artifact Contents Regenerated
gen/registry.go Schema() descriptor: models, fields, CRDT kinds, topics, schema version, schema hash; reducer, projection, and effect registries always
gen/<model>/repo.go Of(ws), Get, Create, Update, Delete, Query() always
gen/<model>/update.go NoteUpdate — one CRDT-appropriate verb per field: Title.Set, Tags.Add/Remove, Views.Inc always
gen/<model>/query.go single-model predicates, ordering, limits always
gen/<model>/codec.go protobuf marshal/unmarshal per op kind always
gen/<model>/reducer.go deterministic merge SQL per kind — readable and steppable always
gen/<model>/topic.go TopicChanged = "local:notes-changed" always
gen/db/*.go sqlc output: typed methods for queries/*.sql always
gen/proto/*.pb.go generated Go for gen/ops.proto always
gen/schema.sql consolidated DDL — framework + domain + projection tables. sqlc's only schema input always
gen/fingerprint.go canonical state hash for EventuallyEqual (write model only) always
migrations/00NN_*.sql forward DDL, additive-only, written once then frozen append-only

The typed surface does real teaching work in both directions. On write, Views has no Set and Tags has no Set, so choosing the wrong CRDT tag in schema/note.go surfaces as a compile error at the call site rather than as a convergence bug months later. On read, the generated type has no assignable fields at all, so the only way to change data is the only way that produces an operation. Neither rule needs to be remembered — both are enforced by the compiler.

5.3 Boundaries that matter

  • Generated code depends only on local/*, schema/, and database/sql. It never imports the author's handlers.
  • Migrations are generated but immutable once released. allons generate diffs the desired DDL against the applied set and emits a new numbered migration, or fails loudly if a released migration would need to change. Frozen migrations are recorded with their SHA-256 in migrations/lock.json.
  • The schema hash (BLAKE3 over the canonical model descriptor) is baked into the binary and into HELLO. A mismatch between two peers claiming the same schema_version is a hard error — it means someone shipped a build with an edited generator output. This catches the single nastiest class of silent divergence.
  • Reducers are generated code you can read and step through in a debugger, not table-driven interpretation. If a CRDT semantic is wrong, you can see exactly what SQL runs.
  • Escape hatch: a field tagged local:"custom" is merged by a hand-written reducer declared with +allons:op kind=… and living in schema/note.go, next to the struct whose semantics it defines. Custom reducers must pass the framework's determinism property test (§13.4) or allons test fails, and the determinism lint — no time.Now(), no map iteration, no floats — keys on any method satisfying the reducer interface rather than on a filename, so file organization carries no meaning.
  • Generated code is committed, not ignored. gen/registry.go carries the schema hash that peers hard-reject on (§10.2); if that value changes outside of review, a build that silently misinterprets operations ships without anyone seeing it. The same argument applies to reducer diffs: when merge semantics change, that belongs in the pull request. allons generate --check in CI is what makes committing safe — it fails on stale output.
  • Every generated Go file opens with // Code generated by allons. DO NOT EDIT. — the stdlib-recognised form, honoured by gopls, linters, and coverage tooling. .gitattributes marks gen/** as linguist-generated so diffs collapse by default.

sqlc owns the read side, and is fenced. allons generate emits gen/schema.sql and then invokes sqlc (vendored via go tool, so no separate install) against your .sql query files. Two rules are enforced at generate time:

  1. Every sqlc query must be a SELECT. An INSERT or UPDATE through sqlc would mutate state without signing and appending an operation — the replica would diverge silently, with no error and no detection short of a fingerprint mismatch in testing. This is the most dangerous mistake available in the framework, so the generator parses each query and rejects anything else outright. Writes go through generated repositories, always.
  2. Queries may target projections and views, not raw CRDT metadata columns. Reading title_hlc in application code is a sign that merge semantics are leaking upward.

allons generate --check fails CI if sqlc output is stale relative to the schema.

Because migrations are additive-only, +allons:deprecated columns persist in gen/schema.sql indefinitely. The generator emits matching sqlc column: exclude entries for them automatically, so deprecated fields vanish from the generated Go types while the columns remain in SQLite. Dead columns stay invisible to application code without anyone maintaining a suppression list.

Migrations are additive-only, enforced. The generator rejects DROP COLUMN, DROP TABLE, and renames. Use +allons:deprecated to remove a field from the Go API while leaving the column in place. This makes app downgrade — brew pinning an older version, a pulled release, a rolled-back go install — safe by construction: an older binary reads only the columns it knows about, and operations it cannot interpret already quarantine (§10.2). The cost is that schema grows monotonically; reclaiming dead columns requires a coordinated epoch boundary where every peer has upgraded, which is rare and deliberate.

Not generated: handlers, templates, validation, policies, and tests. Those are the app.


6. SQLite schema

WAL mode, foreign_keys=ON, synchronous=NORMAL, busy_timeout=5000, single writer goroutine (a *sql.DB with SetMaxOpenConns(1) for the write pool, plus a separate read-only pool with N connections). Driver: modernc.org/sqlite by default — pure Go, so the only C in the build is iroh-ffi. mattn/go-sqlite3 is selectable via build tag for teams that need extensions.

6.1 Framework tables

-- ---------- identity & workspace ----------
CREATE TABLE workspaces (
  workspace_id  BLOB PRIMARY KEY,          -- 32B, = hash of genesis op
  name          TEXT NOT NULL,
  genesis_op    BLOB NOT NULL,
  created_hlc   INTEGER NOT NULL,
  epoch_id      BLOB NOT NULL,             -- current compaction epoch
  degraded      TEXT                       -- NULL | 'upgrade-required' | 'quarantined'
) STRICT;

CREATE TABLE local_identity (          -- exactly one row; never replicated
  rowid_guard   INTEGER PRIMARY KEY CHECK (rowid_guard = 1),
  user_id       BLOB NOT NULL,             -- ed25519 pub, human identity
  replica_id    BLOB NOT NULL,             -- ed25519 pub, this device's signing key
  endpoint_id   BLOB NOT NULL,             -- iroh EndpointId (rotatable)
  device_label  TEXT NOT NULL,
  created_at    INTEGER NOT NULL
) STRICT;

CREATE TABLE members (                 -- materialized view of auth.* ops
  workspace_id  BLOB NOT NULL,
  replica_id    BLOB NOT NULL,
  user_id       BLOB NOT NULL,
  role          INTEGER NOT NULL,          -- 0 reader 1 editor 2 admin 3 owner
  not_before    INTEGER NOT NULL,          -- HLC
  not_after     INTEGER,                   -- HLC, NULL = no expiry
  granted_by    BLOB NOT NULL,
  granted_hlc   INTEGER NOT NULL,
  cert          BLOB NOT NULL,             -- the signed capability, retained verbatim
  PRIMARY KEY (workspace_id, replica_id)
) STRICT;

CREATE TABLE membership_history (      -- durable, append-only, never compacted
  workspace_id  BLOB NOT NULL,
  seq           INTEGER NOT NULL,          -- per-workspace monotonic (local view order)
  op_id         BLOB NOT NULL,
  kind          TEXT NOT NULL,             -- grant | revoke | rotate | retire | transfer
  subject       BLOB NOT NULL,
  role          INTEGER,
  effective_hlc INTEGER NOT NULL,
  actor         BLOB NOT NULL,
  cert          BLOB NOT NULL,
  PRIMARY KEY (workspace_id, seq)
) STRICT;

CREATE TABLE revocations (
  workspace_id  BLOB NOT NULL,
  replica_id    BLOB NOT NULL,
  effective_hlc INTEGER NOT NULL,          -- ops with hlc >= this are invalid
  reason        TEXT,
  revoked_by    BLOB NOT NULL,
  cert          BLOB NOT NULL,
  PRIMARY KEY (workspace_id, replica_id, effective_hlc)
) STRICT;

CREATE TABLE endpoints (               -- replica_id ↔ EndpointId hints, many-to-one
  workspace_id  BLOB NOT NULL,
  replica_id    BLOB NOT NULL,
  endpoint_id   BLOB NOT NULL,
  last_seen     INTEGER,
  relay_url     TEXT,
  PRIMARY KEY (workspace_id, replica_id, endpoint_id)
) STRICT;

-- ---------- the operation log ----------
CREATE TABLE ops (
  op_id         BLOB PRIMARY KEY,          -- 32B BLAKE3 of canonical body → idempotent
  workspace_id  BLOB NOT NULL,
  replica_id    BLOB NOT NULL,
  seq           INTEGER NOT NULL,          -- monotonic per replica, gapless
  hlc           INTEGER NOT NULL,          -- packed: 48b wall_ms | 16b counter
  schema_ver    INTEGER NOT NULL,
  epoch_id      BLOB NOT NULL,
  kind          TEXT NOT NULL,
  payload       BLOB NOT NULL,
  signature     BLOB NOT NULL,
  received_at   INTEGER NOT NULL,
  applied       INTEGER NOT NULL DEFAULT 0, -- 0 = quarantined (unknown kind/version)
  UNIQUE (workspace_id, replica_id, seq)
) STRICT;
CREATE INDEX ops_by_ws_hlc   ON ops(workspace_id, hlc);
CREATE INDEX ops_unapplied   ON ops(workspace_id, applied) WHERE applied = 0;

CREATE TABLE replica_seq (             -- our own next sequence number
  workspace_id  BLOB PRIMARY KEY,
  next_seq      INTEGER NOT NULL,
  last_hlc      INTEGER NOT NULL       -- for HLC monotonicity across restarts
) STRICT;

CREATE TABLE peer_heads (              -- what WE have from each replica
  workspace_id   BLOB NOT NULL,
  replica_id     BLOB NOT NULL,
  contiguous_seq INTEGER NOT NULL,     -- highest gapless seq we hold
  max_seq        INTEGER NOT NULL,     -- highest seq seen (may exceed contiguous)
  PRIMARY KEY (workspace_id, replica_id)
) STRICT;

CREATE TABLE peer_acks (               -- what a PEER told us it durably has
  workspace_id   BLOB NOT NULL,
  peer_replica   BLOB NOT NULL,        -- who acked
  replica_id     BLOB NOT NULL,        -- whose ops
  acked_seq      INTEGER NOT NULL,
  acked_at       INTEGER NOT NULL,
  PRIMARY KEY (workspace_id, peer_replica, replica_id)
) STRICT;

-- ---------- compaction & snapshots ----------
CREATE TABLE epochs (
  workspace_id  BLOB NOT NULL,
  epoch_id      BLOB NOT NULL,
  parent_epoch  BLOB,
  floor_hlc     INTEGER NOT NULL,      -- ops older than this are no longer accepted
  created_hlc   INTEGER NOT NULL,
  created_by    BLOB NOT NULL,
  snapshot_hash BLOB NOT NULL,         -- BLAKE3 of canonical snapshot bytes
  cert          BLOB NOT NULL,
  PRIMARY KEY (workspace_id, epoch_id)
) STRICT;

CREATE TABLE snapshots (
  workspace_id  BLOB NOT NULL,
  epoch_id      BLOB NOT NULL,
  chunk_no      INTEGER NOT NULL,
  bytes         BLOB NOT NULL,
  PRIMARY KEY (workspace_id, epoch_id, chunk_no)
) STRICT;

-- ---------- attachments ----------
CREATE TABLE attachments (
  workspace_id  BLOB NOT NULL,
  hash          BLOB NOT NULL,         -- BLAKE3-256
  size          INTEGER NOT NULL,
  mime          TEXT,
  state         INTEGER NOT NULL,      -- 0 metadata-only 1 fetching 2 present 3 failed
  refcount      INTEGER NOT NULL DEFAULT 0,
  first_seen    INTEGER NOT NULL,
  last_ref_hlc  INTEGER NOT NULL,
  PRIMARY KEY (workspace_id, hash)
) STRICT;

CREATE TABLE blob_gc_candidates (
  hash          BLOB PRIMARY KEY,
  unreferenced_since INTEGER NOT NULL
) STRICT;

-- ---------- versions ----------
CREATE TABLE schema_meta (
  key   TEXT PRIMARY KEY,
  value TEXT NOT NULL
) STRICT;
-- rows: db_migration_version, app_schema_version, app_schema_min_readable,
--       app_schema_hash, protocol_version, protocol_min, framework_version

6.2 Generated domain tables

For Note, the generator emits materialized state plus per-field CRDT metadata inline:

CREATE TABLE notes (
  workspace_id BLOB NOT NULL,
  id           BLOB NOT NULL,

  title        TEXT,
  title_hlc    INTEGER NOT NULL DEFAULT 0,
  title_rep    BLOB,                       -- tie-break: replica_id

  body         TEXT,
  body_hlc     INTEGER NOT NULL DEFAULT 0,
  body_rep     BLOB,

  pinned       INTEGER,
  pinned_hlc   INTEGER NOT NULL DEFAULT 0,
  pinned_rep   BLOB,

  cover_hash   BLOB,                       -- FK-ish into attachments
  cover_hlc    INTEGER NOT NULL DEFAULT 0,
  cover_rep    BLOB,

  deleted      INTEGER NOT NULL DEFAULT 0,
  deleted_hlc  INTEGER NOT NULL DEFAULT 0,
  deleted_rep  BLOB,

  created_hlc  INTEGER NOT NULL,
  updated_hlc  INTEGER NOT NULL,
  PRIMARY KEY (workspace_id, id)
) STRICT;

-- ORSet element table: add-wins with unique tags
CREATE TABLE notes_tags (
  workspace_id BLOB NOT NULL,
  id           BLOB NOT NULL,
  element      TEXT NOT NULL,
  tag          BLOB NOT NULL,              -- unique add-tag = op_id ‖ index
  removed      INTEGER NOT NULL DEFAULT 0, -- tombstoned add-tag
  hlc          INTEGER NOT NULL,
  PRIMARY KEY (workspace_id, id, element, tag)
) STRICT;
CREATE INDEX notes_tags_live ON notes_tags(workspace_id, id, element) WHERE removed = 0;

-- Counter: per-replica PN pair; value = SUM(pos) - SUM(neg)
CREATE TABLE notes_views (
  workspace_id BLOB NOT NULL,
  id           BLOB NOT NULL,
  replica_id   BLOB NOT NULL,
  pos          INTEGER NOT NULL DEFAULT 0,
  neg          INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (workspace_id, id, replica_id)
) STRICT;

Reads use a generated view so queries stay ordinary SQL:

CREATE VIEW notes_v AS
SELECT n.workspace_id, n.id, n.title, n.body, n.pinned, n.cover_hash,
       n.created_hlc, n.updated_hlc,
       (SELECT COALESCE(SUM(pos)-SUM(neg),0) FROM notes_views v
         WHERE v.workspace_id=n.workspace_id AND v.id=n.id) AS views
FROM notes n WHERE n.deleted = 0;

Why per-field metadata columns rather than a generic triple store: queries are plain SQL with real indexes, EXPLAIN QUERY PLAN is meaningful, and the storage cost is bounded and visible. The cost is wider tables and a migration per added field — acceptable, and it is exactly what a code generator is for.

6.3 Migrations

pressly/goose with //go:embed migrations/*.sql, run inside Open before any other statement, under an exclusive transaction. Two independent version axes, both recorded in schema_meta:

  • db_migration_version — local storage layout. Never travels over the wire.
  • app_schema_version + app_schema_min_readable — the semantic version of operations. This is what HELLO negotiates (§8).

A local DB migration that only adds an index does not change app_schema_version. A new field on Note changes both. Adding or bumping a projection (§7.5) changes neither — projections never travel over the wire, so they cost nothing in protocol compatibility.

Migrations are additive-only (§5). Combined with quarantine (§10.2), that makes running an older binary against a newer database a non-event rather than an undefined failure.

6.4 The draft-migration workflow

Append-only, frozen migrations and regenerate-on-save are in direct tension: renaming a field three times during an afternoon's experimentation would otherwise produce three permanent migrations and three schema-version bumps. The resolution is an explicit draft state.

schema/note.go edited
      │
      ▼
allons dev regenerates ──▶ migrations/0007_draft.sql        ← rewritten freely,
      │                    schema_meta.draft = true            never frozen
      │                    dev DB auto-recreated on any
      │                    destructive change (dev data is disposable)
      ▼
allons schema seal ──────▶ migrations/0007_add_tags.sql     ← hash recorded in
                           app_schema_version 3 → 4            lock.json, immutable
                           draft cleared

Rules:

  • Exactly one draft may exist. It absorbs every change since the last seal, and allons generate rewrites it in place rather than appending.

  • Drafts are fine locally; they just cannot be distributed. allons dev and allons build both work with a draft, because needing a standalone binary before you are ready to freeze a schema is normal. allons package and allons release refuse, with the fix in the error message.

    A draft build is safe by construction rather than by policy: it advertises a schema hash derived from the unsealed schema, so it hard-rejects (§10.2) against any sealed peer and can only sync with identical draft builds. That is exactly what dev --replicas 3 runs, and exactly what you want — experimentation converges among itself and cannot poison a real workspace.

  • allons schema seal freezes the draft, assigns the real migration number and name, records its SHA-256 in lock.json, and bumps app_schema_version if the change is semantic.

  • A destructive draft change resets the dev database, and says so. Never silently. The dev data directory is isolated from any real workspace, the previous database is kept as a one-generation backup, and the exact path is printed:

    schema changed · Note.Pinned bool → string
    dev database reset · .allons/dev/alice/app.db
      backup: .allons/dev/alice/app.db.prev
      keep your data instead with: allons dev --keep-data

    --keep-data fails the regeneration instead of resetting, for when the dev database holds a reproduction you care about.

  • allons protocol-check explains each change rather than just classifying it: which field, which op kind, why it is compatible / requires a bump / is forbidden, and what to do instead.

$ allons protocol-check
  notes.Note +Tags []string orset          compatible      additive field, new op kind
  notes.Note  Pinned lww → orset           FORBIDDEN       changing a CRDT type on a
                                                           released field would make
                                                           peers on schema 3 misread
                                                           notes.update payloads.
                                                           Add a new field instead.

This is the workflow a developer touches more than any other. Getting it wrong produces either migration churn or accidental incompatibility, so it is specified here rather than left to the CLI.

6.5 Schema changes across branches

Two people adding fields on two branches is a daily event at the target scale, and "exactly one draft" only describes one working copy. The rules that make branching safe:

Drafts are never committed. migrations/*_draft.sql is in .gitignore. A draft is one machine's scratch space; only a sealed migration enters version control. This removes the largest category of conflict before it exists.

Sealed-but-unreleased migrations are renumberable; released ones are not. lock.json records, per migration, its hash and whether allons release has shipped it. Renumbering something already in someone's hands would change a schema hash peers hard-reject on (§10.2), so schema rebase refuses it and says why.

lock.json is one line per migration, sorted by number, so independent additions merge textually without conflict most of the time. When they do conflict, regenerate rather than hand-edit.

Never hand-resolve gen/. .gitattributes marks it -merge so git does not attempt a textual merge. Take either side and regenerate; the content is a pure function of schema/ and queries/.

allons schema rebase           re-diff the branch's schema against the newly sealed
                               baseline, renumber this branch's unreleased migrations,
                               regenerate gen/ and lock.json, rerun protocol-check

A worked two-PR merge. Both branches start from sealed 0006, app_schema_version 3.

main            0006  v3
 ├── alice/tags    seals 0007_notes_tags        v4   → merged first
 └── bob/pinned    seals 0007_notes_pinned      v4   → now stale

Bob rebases onto main and runs one command:

$ allons schema rebase
  baseline moved   0006 → 0007_notes_tags (v4, unreleased)
  renumbered       0007_notes_pinned → 0008_notes_pinned
  app_schema       4 → 5
  regenerated      gen/ (11 files, 3 changed)
  protocol-check   compatible — additive field, new op kind

  note: 0007_notes_tags is sealed but unreleased. If both had shipped,
        this would be a genuine incompatibility rather than a renumber.

Both migrations are additive (§5), so their order does not matter to the resulting schema — which is why renumbering is safe rather than merely convenient. The app_schema_version collision resolves the same way: neither shipped, so the second simply takes the next number.

If both versions had been released, rebase stops and reports a real conflict: two builds in the field claim v4 with different schema hashes, and the fix is a coordinated release rather than a renumber.


7. Operation model and conflict semantics

7.1 The envelope

// envelope.proto — framework-owned, frozen, versioned by protocol not schema
message Envelope {
  bytes  op_id        = 1;  // 32B BLAKE3-256 of canonical_body()
  bytes  workspace_id = 2;  // 32B
  bytes  replica_id   = 3;  // 32B ed25519 public key (the operation-signing key)
  uint64 seq          = 4;  // monotonic, gapless, per (workspace, replica)
  uint64 hlc          = 5;  // 48b unix_ms | 16b counter
  uint32 schema_ver   = 6;
  bytes  epoch_id     = 7;  // epoch under which this op was created
  string kind         = 8;  // "notes.update", "auth.grant", …
  bytes  payload      = 9;  // deterministic protobuf of the kind's payload
  bytes  signature    = 10; // ed25519 over DOMAIN ‖ canonical_body()
}

canonical_body() = protobuf serialization with fields in tag order, no unknown fields preserved, deterministic map ordering (we use no maps in payloads), fields 1 and 10 omitted. op_id = BLAKE3("allons.op.v1" ‖ canonical_body()). Signature domain-separated with "allons.sig.v1".

Consequences:

  • op_id is a content hash ⇒ duplicate delivery is free (INSERT OR IGNORE on the primary key).
  • The signature covers seq, hlc, epoch_id, and schema_ver ⇒ a peer cannot forward a mutated or re-sequenced operation.
  • Forwarding is safe by construction: any peer can relay any op, and the receiver's verification does not depend on who delivered it. This is what makes third-party gossip and store-and-forward work after the original author goes offline.

7.2 Hybrid logical clocks

func (c *Clock) Now() HLC {
    wall := uint64(time.Now().UnixMilli())
    if wall > c.lastWall { c.lastWall, c.counter = wall, 0 } else { c.counter++ }
    return pack(c.lastWall, c.counter)
}
func (c *Clock) Observe(remote HLC) {   // on every accepted inbound op
    wall := max(uint64(time.Now().UnixMilli()), remote.Wall())
    if wall == c.lastWall { c.counter = max(c.counter, remote.Counter()) + 1 }
    else if wall == remote.Wall() { c.counter = remote.Counter() + 1 }
    else { c.counter = 0 }
    c.lastWall = wall
}

last_hlc is persisted in replica_seq on every commit, so a clock that jumps backwards across a restart cannot regress. Ops arriving with wall > now + MaxClockSkew (default 5 min) are accepted but flagged — rejecting them would let one bad clock permanently partition a workspace; flagging them surfaces the problem in diagnostics. Ops beyond MaxClockDrift (default 24h ahead) are rejected as malformed.

7.3 The four replicated types

Tag Type Merge rule Tie-break
lww Last-writer-wins register apply if (hlc, rep) > (cur_hlc, cur_rep) higher HLC, then lexicographically greater replica_id, then greater op_id
orset Observed-remove set add inserts (element, tag=op_id‖i); remove marks only the tags the remover observed add-wins: concurrent add+remove ⇒ element present
counter PN-counter per-replica (pos, neg) accumulation, INSERT … ON CONFLICT DO UPDATE SET pos = pos + excluded.pos none needed, commutative
tombstone Delete flag LWW register over a boolean on equal HLC, delete wins (deleted=1 sorts above 0), then replica_id

Every merge is a single generated UPDATE … WHERE (hlc, rep) < (?, ?) — idempotent, commutative, and associative, which is the definition of convergence for these types. Re-applying an op is a no-op because the comparison fails.

Delete semantics. A tombstone does not remove rows; it sets deleted=1 and (at reduce time) drops the row's blob refcounts. Field updates concurrent with a delete still apply to the underlying columns — so an undelete with a later HLC restores a merged record, not a stale one. This is the behavior users expect ("I deleted it, Bob edited the title, I restored it → Bob's title is there").

What we deliberately do not do in v1: rich text / sequence CRDTs, moves in trees, referential integrity across replicas, unique constraints on replicated columns (two replicas can concurrently create the same "unique" title — the app must tolerate it or use a deterministic key). Every one of these is a research-grade problem and none is needed for a first real application.

7.4 Applying an operation (the single reducer path)

apply(tx, env):
  1. size / frame limits already enforced upstream
  2. if ops.op_id exists            → return AlreadyApplied      (idempotent, cheap)
  3. verify signature(env)          → else Reject(BadSignature)  (before any decode)
  4. if env.hlc < epoch.floor_hlc   → Reject(BelowEpochFloor)    (anti-resurrection)
  5. membership.RoleAt(env.replica_id, env.hlc) must permit env.kind
     and not revocations.RevokedAt(env.replica_id, env.hlc)
                                    → else Reject(Unauthorized)
  6. if kind/schema_ver unknown     → INSERT ops(applied=0)      (quarantine, forwardable)
  7. reducer[kind].Apply(tx, header, payload)   ← generated, deterministic
  8. INSERT ops(applied=1); advance peer_heads; clock.Observe(env.hlc)
  9. tx.Notify(topic_for(kind))

Step 3 before step 7 is the "verify before expensive work" rule. Step 6 is what makes mixed-version meshes survivable (§10).

7.5 The read model: projections

The write model above is deliberately narrow, because it is a wire contract — every peer must interpret op kinds and CRDT semantics identically, forever, and widening them later is a schema negotiation across a mesh containing a laptop that has been shut for six weeks. Being conservative there is what keeps convergence provable.

The read model has none of that cost. Nothing about a projection travels over the wire. Adding one is a purely local migration: no protocol bump, no peer coordination, no compatibility matrix. So projections are first-class, plural, versioned, and independently rebuildable — the query target is not welded to the domain struct.

Which of the four read paths to use — the daily decision, not the architecture:

You want to… Use Cost
Filter or order one model generated repository Query() none, already there
Join, aggregate, search, or shape a custom result a .sql file in queries/ → sqlc one file
Maintain expensive derived data incrementally StateProjection a table + Refresh
Show history, attribution, or anything where operation order matters OpProjection a table + Apply, plus the horizon caveat below

The common mistakes this table is meant to prevent: reaching for a projection when a join would do, and reaching for sqlc when the repository already answers it. Most applications need exactly one projection and a handful of sqlc queries.

type Projection interface {
    Name() string
    Version() int                        // bump → drop and rebuild
    Migrate(tx *sql.Tx) error            // its own DDL; local-only, additive-only
}

// StateProjection — the default. Recomputes from converged CRDT state.
type StateProjection interface {
    Projection
    Refresh(tx *sql.Tx, changed []local.EntityRef) error
}

// OpProjection — the escape hatch. Sees every operation in HLC order.
type OpProjection interface {
    Projection
    Apply(tx *sql.Tx, h oplog.Header, payload []byte) error
}

Projections run inside the same transaction as the reduction that triggered them, so a query never observes state and projection out of step.

Which flavor to reach for. State-derived projections are correct regardless of arrival order and rebuild fully from a snapshot — denormalized joins, FTS indexes, aggregates, counts, anything whose value is a function of the current state. Op-derived projections express what the state cannot: activity feeds, audit trails, per-author attribution, "what changed while I was away." They carry two caveats that must be documented rather than discovered:

  • A retroactively-arriving operation from a long-offline peer lands in the middle of the feed, not at the end. Any UI over an op projection needs to tolerate insertion-in-the-past.
  • Rebuild only reaches back to the epoch floor (§12). Below it the operations are gone, so an op projection's history has the same horizon as compaction. Rebuild starts from the snapshot and replays post-floor ops, and the truncation is surfaced rather than smoothed over — a feed shows "history before 12 March has been archived" and stops.

The boundary that must not blur: Fingerprint() for EventuallyEqual (§13) is computed over the write model only — materialized CRDT state and its metadata, never projections. Projections are explicitly permitted to be order-dependent; if they fed convergence assertions, a perfectly reasonable activity feed would fail the test suite for doing its job.

Rebuild is a first-class operation, not a recovery hack. gen/registry.go records each projection's version and last-applied position; a version bump drops its tables and replays. This is the same machinery as §16.3's rebuild-from-log, generalized. It also means a projection bug ships as a version bump rather than a data migration.

7.6 Effects

An operation applies on every replica. An effect hung naively off a reducer therefore fires once per device in the mesh — which is fine for reindexing and catastrophic for sending email.

Effects are local-only, never replicated, never part of the fingerprint, and dispatched from a durable outbox written in the same SQLite transaction as the state change. That closes the crash window between "committed" and "will fire" without a second store.

type Effect interface {
    Name() string
    Scope() EffectScope
    UserVisible() bool                               // see dead-letter policy below
    Run(ctx context.Context, e EffectRecord) error   // idempotent; may re-run
}

const (
    OnLocalCommit EffectScope = iota // ONLY on the replica that authored the op.
                                     // Default for anything reaching the outside world.
    OnAnyApply                       // On every replica. For local-only work:
                                     // blob prefetch, FTS reindex, desktop notification.
)
CREATE TABLE effect_outbox (
  id           INTEGER PRIMARY KEY,
  effect       TEXT NOT NULL,
  idem_key     BLOB NOT NULL,        -- usually op_id ‖ effect name
  payload      BLOB NOT NULL,
  attempts     INTEGER NOT NULL DEFAULT 0,
  next_attempt INTEGER NOT NULL,
  state        INTEGER NOT NULL,     -- 0 pending 1 running 2 done 3 dead
  last_error   TEXT,
  UNIQUE (effect, idem_key)
) STRICT;

A single dispatcher goroutine per workspace drains the outbox with exponential backoff. Effects are retried, so they must be idempotent; the idem_key is provided for external systems that can deduplicate.

Dead letters are classified, not uniform. An exhausted OnLocalCommit effect is usually consequential and silent — the invite email never sent, and the user believes it did. An exhausted OnAnyApply effect is usually background noise that will retry on the next apply. UserVisible() splits them: true means a persistent, dismissible notification carrying a retry action; false means diagnostics only. The method lands in phase 1b even though the notification UX is phase 8, because effects written before the classification exists would all inherit a default that is wrong for half of them.

There is no OnLeader scope. Exactly-once-across-the-workspace is a configuration decision on an always-on replica — an explicit "this machine sends the digest email" flag — rather than a distributed election, which would need a quorum protocol this design has no other use for.


8. Replication protocol

8.1 Framing

ALPN: dev.example.app/sync/1 (derived from AppID — apps never share an ALPN).

frame := u32be length ‖ protobuf(Frame)
length ≤ MaxFrame (default 1 MiB; blob chunk frames capped at 256 KiB + header)
message Frame {
  oneof msg {
    Hello hello = 1;  HelloAck hello_ack = 2;  Want want = 3;
    Ops   ops   = 4;  Ack ack = 5;             BlobWant blob_want = 6;
    BlobChunk blob_chunk = 7;  Peers peers = 8; Status status = 9;
    Bye bye = 10;     SnapshotWant snap_want = 11;  SnapshotChunk snap_chunk = 12;
  }
}

message Hello {
  bytes  workspace_id      = 1;
  bytes  replica_id        = 2;
  bytes  channel_binding   = 3;  // BLAKE3(local_endpoint ‖ remote_endpoint ‖ nonce)
  bytes  proof             = 4;  // ed25519(replica_key, "allons.hello.v1" ‖ binding)
  bytes  membership_cert   = 5;  // our capability, so a cold peer can authorize us
  uint32 protocol_version  = 6;
  uint32 protocol_min      = 7;
  uint32 schema_version    = 8;
  uint32 schema_min_read   = 9;
  bytes  schema_hash       = 10;
  bytes  epoch_id          = 11;
  repeated Head heads      = 12; // {replica_id, contiguous_seq}
  repeated string features = 13; // "blobs","snapshots","zstd"
  uint32 max_frame         = 14;
  string replica_class     = 15; // "full" is the ONLY legal value in v1.
                                 // Reserved so leaf/partial replicas can be added
                                 // as a feature negotiation, not a protocol break.
}

message Want { repeated Range ranges = 1; repeated bytes op_ids = 2; bytes epoch_id = 3; }
message Range { bytes replica_id = 1; uint64 from_seq = 2; uint64 to_seq = 3; }
message Ops  { repeated bytes envelopes = 1; bool more = 2; }      // ≤ 256 per batch
message Ack  { repeated Head durable = 1; }

8.2 Stream discipline

Stream Type Opened by Carries
control bi dialer HELLO, HELLO_ACK, WANT, ACK, PEERS, STATUS, BYE
ops uni either OPS batches (one stream per batch run)
blobs bi requester BLOB_WANT → BLOB_CHUNK*
snapshot bi requester SNAPSHOT_WANT → SNAPSHOT_CHUNK*

QUIC streams are independently flow-controlled, so a 400 MB attachment transfer cannot head-of-line-block an ACK. Ops go on unidirectional streams because the sender never needs a reply on that stream — durability is confirmed out-of-band on the control stream via ACK, which is the correct decoupling: ACK means "fsynced", not "received".

Datagrams carry exactly one thing: Status{hint: head_bumped, replica_id, seq} — a nudge that lets a peer skip the anti-entropy interval. Dropping every datagram costs at most AntiEntropyInterval (default 30s, jittered) of latency and nothing else. There is a test (TestConvergenceWithAllDatagramsDropped) that runs the entire fault matrix with datagrams disabled.

8.3 Session state machine

                    ┌──────────┐
       ticket/hint  │   Idle   │◀──────────────────────────┐
            ┌──────▶└────┬─────┘                           │
            │            │ dial                            │ backoff expired
            │            ▼                                 │
            │      ┌───────────┐  dial fail / timeout  ┌────┴─────┐
            │      │  Dialing  │──────────────────────▶│ Backoff  │
            │      └────┬──────┘                       └────▲─────┘
            │           │ conn established                  │
            │           ▼                                   │
            │    ┌──────────────┐  bad proof / not a member │
            │    │ Handshaking  │───────────────────────────┤
            │    └────┬─────────┘  ─────────────┐           │
            │         │ compatible               │ version   │
            │         ▼                          ▼ mismatch  │
            │   ┌────────────┐            ┌──────────────┐   │
            │   │ CatchingUp │            │ Incompatible │───┤
            │   │ WANT→OPS   │            │ (surface UI, │   │
            │   └────┬───────┘            │  no ops)     │   │
            │        │ heads converged    └──────────────┘   │
            │        ▼                                       │
            │   ┌────────────┐   strike limit / frame abuse  │
            │   │    Live    │───────────────────────────────┤
            │   │ stream new │   peer revoked → Ejected      │
            │   │ ops + ACK  │                               │
            │   └────┬───────┘                               │
            │        │ Close / shutdown                      │
            │        ▼                                       │
            │   ┌──────────┐                                 │
            └───│ Draining │─────────────────────────────────┘
                │ flush ACK│
                └──────────┘

State transitions in detail:

Handshaking. Both sides send HELLO simultaneously (no round-trip penalty). Each verifies: (a) proof over the channel binding, which ties the workspace identity to this QUIC connection independent of EndpointId; (b) membership_cert chains to a known admin or owner for workspace_id; (c) the peer is not revoked as of now; (d) version compatibility (§10). Failure ⇒ CloseWith(code, reason) and a Bye if the connection is still usable, so the UI can say why.

CatchingUp. For every replica_id in union(remote.heads, local.heads):

  • remote head > our contiguous_seqWant{Range{rep, contiguous+1, remote_head}}
  • our head > remote's ⇒ we will receive their WANT and stream from ops

Requests are chunked (≤ MaxWantSpan, default 10 000 seqs) so a peer that has been offline for a year does not ask for one enormous stream. Out-of-order or duplicate ops are harmless: op_id is the primary key and reducers are idempotent. We advance contiguous_seq only when the gap fills; max_seq tracks the frontier so the UI can show "3 of 200 operations behind".

Live. On local commit, the engine pushes the new envelopes to every Live session (batched, ≤ 256 ops or 512 KiB per batch, whichever first) and sends a Status datagram hint. Peers ACK their durable heads at most every 2 s or every 64 ops. peer_acks is what feeds "fully synchronized" in the UI and the compaction floor in §12.

Anti-entropy. Every 30s ± 40% jitter, a Live session re-exchanges heads (a small Hello-lite Status frame) and re-derives WANTs. This is the correctness backstop: if every push is lost and every datagram dropped, anti-entropy alone converges the system. Pushes are an optimization.

Backoff. Exponential from 1s to 5min, full jitter, reset on a successful handshake. Separate budgets for "dial failed" (network) and "handshake rejected" (semantic) — the latter backs off much harder and surfaces to the user rather than retrying quietly.

8.4 Backpressure and abuse limits

  • Per-peer: max 1 control + 2 op + 2 blob + 1 snapshot concurrent stream. Excess ⇒ stream reset with TooManyStreams.
  • Global: MaxPeerSessions (default 32); beyond that, accept-and-close with Busy.
  • Inbound ops are handed to a bounded channel (cap 1 024). When full, we stop reading the stream — QUIC flow control propagates the pressure to the sender, which is the whole reason to use streams rather than datagrams.
  • Strike system: each session accumulates strikes for malformed frames, bad signatures, unauthorized ops, oversize frames, and unknown workspace_id. 3 strikes ⇒ close + 15 min quarantine of that EndpointId; 10 strikes across sessions ⇒ persistent block requiring user action. Strikes are logged with slog at WARN with the peer's replica_id and reason code.
  • Decompression (feature zstd, off by default in v1) is bounded by an absolute output cap and a ratio cap (100:1), enforced by a limited reader — never by trusting a declared size.

8.5 Attachment transfer

requester                                    provider
  │ BLOB_WANT{hash, offset, length} ───────▶ │  check attachments row exists & authorized
  │ ◀─────────────── BLOB_CHUNK{seq, bytes}  │  stream from CAS file, 256 KiB chunks
  │  hash incrementally (BLAKE3)             │
  │ …                                        │
  │ verify final hash == requested           │
  │ fsync tmp → rename into CAS → fsync dir  │
  │ tx: attachments.state = present ─ commit │

Provider-side authorization is per-workspace, and a peer may only request hashes referenced by an attachment row it could have learned about — otherwise the CAS becomes an oracle for arbitrary content the peer never had rights to. Range requests support resume. Failed transfers leave only a tmp file, swept at startup and hourly.


9. Identity and authorization

9.1 Five distinct identities

Identity Key material Lifetime Travels how
User ed25519 root key, in keys/user.key (OS keychain-wrapped when available) years; rotatable signs device records
Device / replica ed25519, keys/replica.key, never leaves the device device lifetime replica_id in every envelope
Iroh endpoint iroh secret key, keys/endpoint.key rotatable freely EndpointId, tickets
Op-signing key = replica key in v1 (separate slot in the schema for future split) device lifetime envelope signature
Capability signed cert, not a key until not_after or revocation auth.* ops + invites

The endpoint key is deliberately not the identity. iroh authenticates the connection; HELLO's proof over the channel binding authenticates the replica. This means: endpoint rotation does not break membership; a stolen endpoint key does not grant workspace write access; and the same replica can be reachable at several EndpointIds (multiple network profiles, migration) without any membership change. endpoints is a hint table, nothing more.

9.2 Capabilities are replicated operations

Membership is not a side channel — it is auth.* operations in the same log, verified by the same pipeline:

message Grant   { bytes subject_replica=1; bytes subject_user=2; uint32 role=3;
                  uint64 not_before=4; uint64 not_after=5; bytes issuer_cert=6; }
message Revoke  { bytes subject_replica=1; uint64 effective_hlc=2; string reason=3; }
message Retire  { bytes subject_replica=1; uint64 effective_hlc=2; }  // for compaction
message Rotate  { bytes old_replica=1; bytes new_replica=2; bytes proof_of_possession=3; }
message Transfer{ bytes new_owner=1; bytes prev_owner_sig=2; }
message Enroll  { bytes invite_id=1; bytes replica_id=2; bytes user_id=3;
                  bytes invite_sig=4; }  // signed by the INVITE key, not an admin

Authorization of any op is evaluated against the membership view as of op.hlc, which is what makes forwarded ops verifiable long after their author disconnects: the receiver needs only the op and the (replicated, durable) membership history — never a live conversation with the author.

9.3 Conflict rules for membership (deliberately not plain LWW)

Membership is the one place where "last writer wins" is dangerous. Rules, in order:

  1. Revoke beats grant at any HLC ≤ the revoke's effective_hlc. Concurrent grant+revoke ⇒ revoked.
  2. Lower role wins on concurrent grants to the same subject.
  3. Ownership is a chain. Transfer must carry a signature from the current owner. Two concurrent transfers fork the chain; resolution is (longest chain, then lowest op_id). See §9.6 — the fork itself is the smaller problem.
  4. Admins cannot revoke the owner. Enforced structurally in the policy.
  5. Expiry is evaluated at op HLC, not wall clock. A capability that expired yesterday still validates ops authored the day before.

9.4 Retroactive revocation

The hard case: Mallory's laptop is stolen at T; the admin revokes at T+1h with effective_hlc = T; but Bob already applied Mallory's ops from T+30m.

Policy:

  • On applying a Revoke with effective_hlc < now, the engine runs a re-evaluation pass: it scans ops for the subject with hlc ≥ effective_hlc, marks them applied = 0 (quarantined, retained for audit), and recomputes the affected entities from the log — bounded by the current epoch, so the replay window is bounded by compaction, not by history length.
  • Recomputation is possible precisely because reducers are deterministic and the log within the epoch is complete. It runs in one transaction, emits local:workspace-changed, and logs an audit record.
  • Entities whose entire history is below the epoch floor cannot be recomputed; those are reported in the diagnostics bundle as "unrecoverable retroactive revocation" with the list of affected IDs. This is an honest limitation, surfaced, not hidden.
  • Revocation propagates as an ordinary op, so an offline peer learns of it whenever it next syncs — and applies the same re-evaluation. Eventual, not immediate; a stolen device that stays connected to a partition it controls will keep writing there. There is no distributed-systems fix for that; the mitigation is capability expiry (default 90 days, renewed automatically while healthy), which bounds the damage window without an online authority.

9.5 Invitations

An invitation is a delegated capability keypair, not a token an admin must be online to validate:

allons-invite:v1:<base32( Invite{ workspace_id, invite_id, invite_pub,
                                  role, not_after, issuer_cert, sig_by_admin } )>
                #<base32(invite_priv)>          ← secret, after the fragment
+ iroh ticket (endpoint id + relay + direct addrs) for the issuing device

Redemption: the joining device generates its own replica key, builds Enroll{invite_id, replica_id, user_id}, signs it with invite_priv, and emits it. Any peer can verify: sig_by_admin proves the admin authorized invite_pub at role R until not_after; invite_sig proves the holder had the invite. No admin needs to be online.

Consequences we accept and document loudly:

  • The invite is a bearer capability. Anyone with the URL can join at role R until expiry. Single-use is not enforceable without an online authority — first-use-wins across a partition is unresolvable. Invite.max_uses is advisory: peers refuse enrollments beyond it once they have observed that many, which converges but does not prevent a concurrent overuse.
  • Default expiry is 24 hours and default role is reader. Admins must opt into longer or higher.
  • Every enrollment lands in membership_history with the invite_id, so "who let this device in" is always answerable.
  • allons inspect invites lists live invites; revoking an invite is a Revoke on invite_pub, which invalidates future enrollments.

9.6 Ownership forks: grandfather and freeze

A fork cannot be prevented, only contained. Two-phase transfer (offer → accept) does not help: concurrent offers to two people can both be accepted across a partition, and "only one outstanding offer" is itself a constraint unenforceable without coordination. So the entire design space is blast radius.

The deterministic rule in §9.3 converges — every replica agrees who the owner is. The danger is downstream: if branch B loses, every admin B granted was never authorized, and every auth.* op those admins signed becomes retroactively invalid, which under §9.4's re-evaluation would unwind weeks of legitimate membership changes and cut off people who did nothing wrong.

Two rules resolve it:

Grandfathering. Capabilities granted under the losing branch remain valid. A bounded set of grants came from an authority that turned out not to hold; every one is recorded in membership_history with its branch, flagged in diagnostics, and left in force. Retroactive invalidation would be more principled and would punish bystanders for a governance accident. Governance forks are resolved by people; the system's job is to not make the damage worse while they do it.

Governance freeze. On detecting a fork, the workspace enters GovernanceFrozen:

  • Data operations flow normally. Nobody stops working.
  • auth.* operations are refused locally and not applied from peers — no new grants, revokes, or transfers — so two halves of an org cannot compound the fork with contradictory membership decisions.
  • The UI shows both claimants, their chains, and who granted what under each.
  • A human acknowledges, which emits auth.fork_resolved signed by the rule-determined winner. Applying it clears the freeze mesh-wide.

The cost is one dialog and one op kind. At small-org scale with real admin turnover, ownership transfer is an operation that will actually get used, and "two people each believe they are owner and both keep granting access" is the failure worth engineering against.


10. Mixed-version behavior

Two independent negotiations in HELLO.

10.1 Protocol version

if remote.protocol_version < local.protocol_min  → close(UpgradeRequiredRemote)
if local.protocol_version  < remote.protocol_min → close(UpgradeRequiredLocal) + UI banner
else                       negotiated = min(local.protocol_version, remote.protocol_version)

max_frame = min(local, remote); features = intersection. Features are strictly additive and each has a defined absence behavior (no zstd ⇒ send uncompressed; no snapshots ⇒ fall back to full-log catch-up or refuse if below epoch floor).

10.2 Schema version

Condition Behavior
remote.schema_version ≤ local.schema_version and ≥ local.schema_min_readable full sync
remote.schema_version > local.schema_version receive-and-quarantine: accept ops, verify signatures, store with applied=0, forward to others, do not reduce. Workspace flagged degraded='upgrade-required', local:upgrade-required event, persistent UI banner. Local writes continue.
remote.schema_version < local.schema_min_readable we cannot interpret their ops; refuse ops from them but still serve ours (they will quarantine). Both sides flagged.
schema_hash differs at equal schema_version hard reject, close(SchemaHashMismatch). This means a tampered or hand-edited generated build. Never sync.

Quarantine is the key idea: an old peer is still a useful relay for new ops, because verification and forwarding require only the envelope, and the envelope format is governed by protocol version, not schema version. A three-device household where one laptop is a version behind still converges the other two through it.

Reducers are registered as (kind, schema_version_introduced, min_readable). Unknown kind or a schema_ver above what the reducer declares readable ⇒ quarantine, never a guess. On upgrade, migration runs and then a quarantine drain re-applies stored applied=0 ops in HLC order — which is safe because reducers are commutative.

Rule for new peers: never reuse a kind string with changed payload semantics. Add notes.update.v2 instead. The generator enforces this by refusing to change a kind's payload shape without a version bump, checked by allons protocol-check against migrations/lock.json.


11. htmx v4 and Wails integration

11.1 Serving

Wails v2's assetserver.Options.Handler receives requests from the WebView in-process. The framework wraps the author's handler:

func (a *App) Handler() http.Handler {
    mux := http.NewServeMux()
    mux.Handle("/_allons/", a.internalRoutes())     // status, diagnostics, blob serving
    mux.Handle("/_allons/bridge.js", a.bridgeJS())  // the event bridge, ~40 lines
    mux.Handle("/static/", http.FileServer(http.FS(a.assets)))
    mux.Handle("/w/{workspace}/", middleware.ExplicitWorkspace(a, a.routes))
    mux.Handle("/", middleware.ActiveWorkspace(a, a.routes))

    return chain(mux,
        middleware.RequestID, middleware.Recover(a.log),
        middleware.SlogRequest(a.log),
        middleware.ReadOnlyGuard(a),   // 503 fragment when DB is recovering
        middleware.CSP(),              // no remote origins; blocks exfil from the WebView
    )
}

Two ways to reach a workspace, and the short one is the default. Unprefixed routes resolve App.Active() — the workspace the window is displaying, which is already a first-class concept because the event bridge filters on it (§11.3). Prefixed /w/{workspace}/… routes resolve the segment explicitly, for workspace switchers and cross-workspace views. Both put a *local.Workspace in the request context, so handlers are identical either way:

func (h *Handlers) ListNotes(w http.ResponseWriter, r *http.Request) error {
    ws := local.WorkspaceFrom(r.Context())   // resolved, authorized, open
    ...
}

// WorkspaceFrom panics if the route was not mounted under a workspace
// middleware — a routing bug that must never reach a user, so it fails loudly
// on the first request in dev with a message naming the missing middleware.
// web.Handler's recover turns it into a 500 with the explanation in the log.
func WorkspaceFrom(ctx context.Context) *Workspace
func TryWorkspace(ctx context.Context) (*Workspace, bool)   // for optional routes

The first-launch problem

On first run there is no active workspace, so a route that requires one cannot serve the screen that creates one. Three strategies, and the default costs the application nothing:

local.AutoWorkspace("Personal")  // default: create and activate on first launch.
                                 // Zero handlers. Hello world never sees this.
local.BuiltinWelcome()           // framework-owned create/join screens at
                                 // /_allons/welcome, styled by your CSS.
local.CustomBootstrap()          // you own /welcome — the explicit form below.

allons new scaffolds AutoWorkspace, so the ten-minute path is newdev → edit a model and a handler → see it in two windows, with no workspace code at all. Applications where joining someone else's workspace is the primary flow switch the field and add their own routes; nothing else changes, because every route resolves a workspace the same way regardless of how the first one came to exist.

The explicit form, for applications that own the experience:

func routes(h *Handlers) http.Handler {
    r := chi.NewRouter()

    // No workspace needed — reachable on first launch.
    r.Method("GET",  "/welcome", web.Handler(h.Welcome))
    r.Method("POST", "/workspaces", web.Handler(h.CreateWorkspace))
    r.Method("POST", "/join", web.Handler(h.JoinWorkspace))

    // Everything else requires one.
    r.Group(func(r chi.Router) {
        r.Use(local.RequireWorkspace)   // no active workspace → 303 to /welcome
        r.Method("GET", "/notes", web.Handler(h.ListNotes))
        // …
    })
    return r
}

Rules:

  • Framework routes (/_allons/…) never require a workspace. doctor, status, and the inspector must work when nothing is open — that is exactly when you need them.
  • local.RequireWorkspace redirects rather than erroring. For a partial request it emits HX-Redirect; for a full request, a 303. The scaffold from allons new wires / to /welcome when Active() reports false.
  • SetActive returns an error for an unknown workspace, a corrupt database, or a failed migration, so the switcher can report why instead of silently showing an empty list.

Scope of "active": process-wide, and it is a UI convenience rather than a security boundary. Wails v2 is single-window in practice; if an application ever opens a second window that needs a different workspace, it uses the explicit /w/{workspace}/… form, which is unaffected by active state. Authorization is always evaluated against the resolved workspace's membership, never against which one happens to be active.

Attachments are served from the CAS at /_allons/blob/{hash} with Cache-Control: immutable — content-addressed URLs are perfectly cacheable, and <img src="/_allons/blob/…"> needs no JS. Missing blobs return a 202 with a placeholder so htmx can retry on local:blob-progress.

11.2 Full versus fragment

htmx v4 sends HX-Request-Type distinguishing full-page from partial requests. web.Render branches on it:

func Render(w http.ResponseWriter, r *http.Request, layout Layout, frag Component) error {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    w.Header().Add("Vary", "HX-Request-Type")
    if IsPartial(r) {
        return frag.Render(r.Context(), w)
    }
    return layout(frag).Render(r.Context(), w)
}

The same handler therefore serves a bookmarkable full page and an htmx swap target, which means "view source", back/forward, and a hard reload all work — a property that is normally the first casualty of a desktop app.

Error handling is intentional, not incidental: validation failures return 422 with a fragment, and the app configures htmx to swap 422 responses for those targets explicitly (per-element hx-swap config rather than a global htmx.config.responseHandling blanket). 5xx never swaps; it fires an error event the shell renders as a toast.

Explicit attribute inheritance (htmx v4's default) means the framework's generated fragments always spell out hx-target, hx-swap, and hx-trigger rather than relying on ancestors — nothing inherits by accident across a swap boundary.

11.3 The event bridge

Go side:

func (b *Bus) BindWails(wctx context.Context) {
    b.emit = func(topic string, detail any) { runtime.EventsEmit(wctx, topic, detail) }
}

Publishing is coalesced: a 80 ms trailing-edge debounce per topic, with a hard flush every 500 ms under sustained load. A remote sync burst of 5 000 ops produces a handful of DOM events, not 5 000.

JS side (/_allons/bridge.js, shipped by the framework, ~40 lines):

const TOPICS = window.__allonsTopics;   // injected from generated Schema()
let active = window.__allonsActiveWorkspace;
const verdict = new Map();             // request id → "suppress" | "dispatch"
const held    = new Map();             // request id → [buffered events]

// Every framework response states what to do with its own event.
document.body.addEventListener('htmx:afterRequest', (e) => {
  const x  = e.detail.xhr;
  const id = x?.getResponseHeader('X-Allons-Request');
  if (!id) return;
  const v = x.getResponseHeader('X-Allons-Echo') || 'dispatch';
  verdict.set(id, v);
  release(id);
  setTimeout(() => { verdict.delete(id); held.delete(id); }, 10000);
});

function release(id) {
  const evts = held.get(id) || [];
  held.delete(id);
  if (verdict.get(id) === 'suppress') return;
  for (const [t, d] of evts) fire(t, d);
}
function fire(t, d) {
  document.body.dispatchEvent(new CustomEvent(t, { detail: d, bubbles: true }));
}

for (const t of TOPICS) {
  window.runtime.EventsOn(t, (detail) => {
    // Workspace scoping: a background workspace ingesting 5,000 ops never
    // thrashes the visible UI, and hx-trigger="local:notes-changed" still works.
    if (detail.workspace && detail.workspace !== active) return;

    // Echo: an event this window caused waits for its own response to say
    // whether that response already rendered the result. Remote changes carry
    // no local request id and fire immediately.
    const id = detail.origin?.request;
    if (id && !verdict.has(id)) {                 // response not back yet
      (held.get(id) || held.set(id, []).get(id)).push([t, detail]);
      setTimeout(() => release(id), 250);         // response never came → dispatch
      return;
    }
    if (id && verdict.get(id) === 'suppress') return;

    fire(t, detail);
  });
}
window.runtime.EventsOn('local:workspace-changed', (d) => { active = d.workspace; });
// Convenience for imperative refreshes
window.allons = {
  refresh: (sel, url) => htmx.ajax('GET', url, { target: sel, swap: 'outerHTML' }),
  status: () => fetch('/_allons/status').then(r => r.json()),
};

Local mutations render once, not twice. A handler that swaps a card has already shown committed state; letting the resulting local:notes-changed also fire would make the enclosing list re-GET immediately, producing a redundant round-trip and a visible flicker. So a change event carries its origin, and the window that caused it suppresses its own echo:

detail = {
  workspace: "…",
  entities:  ["01H8…"],          // which rows changed
  fields:    ["title", "tags"],  // which fields, for conditional refresh
  origin:    { replica: "…", request: "…" }
}

Three cases, one rule:

Change from Origin matches Result
This window's own mutation yes suppressed — the response already rendered it
Another window, same device no event fires, fragments refresh
A remote peer no event fires, fragments refresh

The response decides, not the bridge. The bridge cannot know whether a response rendered anything, and the Wails event and the HTTP response travel different channels with no ordering guarantee — so keying suppression purely on "I made this request" is racy. Instead each framework response states its verdict, and an event that arrives first is held until the response resolves it:

Response helper X-Allons-Echo Effect
web.Fragment, web.Render suppress the response already rendered committed state
web.NoContent dispatch nothing was rendered; let the event drive the refresh
an error path, or no response within 250 ms dispatch (default) refreshing is safer than showing stale data

Defaulting an unresolved event to dispatch rather than suppress is deliberate: a redundant refresh costs a round-trip, a wrongly suppressed one leaves the user looking at data that silently did not update.

Which gives the author the declarative form promised in the brief:

<section hx-get="/notes" hx-trigger="local:notes-changed from:body"></section>

<aside hx-get="/sync"  hx-trigger="local:sync-status from:body, every 30s"></aside>

No WebSocket, no SSE, no long-poll. Wails' event channel is the transport, and it survives WebView reloads because the bridge re-subscribes on load.


12. Snapshots, compaction, and garbage collection

12.1 The single anti-resurrection invariant

An operation with hlc < epoch.floor_hlc is rejected by every peer in that epoch.

Everything else follows. Deleted data cannot be resurrected by a returning peer, because the returning peer's stale ops are below the floor. Tombstones can be dropped once they are below the floor, because nothing older can arrive to contradict them. Long-offline peers get a clean, checkable error rather than silent corruption.

12.2 Cutting an epoch

Only an admin may emit epoch.cut, and the framework refuses to build one unless:

  1. Every non-retired member's peer_acks.acked_seq covers all ops below the proposed floor — durable acknowledgement from the membership roster, not from currently connected peers. A peer that has been offline for a month blocks compaction until it syncs or is explicitly retired.
  2. The proposed floor is at least MinCompactionAge (default 30 days) old.
  3. A snapshot is built and its hash matches on the emitting device.

If a member is blocking, the admin's only path is an explicit, replicated auth.retire op — a deliberate, auditable act with a UI that says "Dana's iPad has not synced since March; retiring it will require it to re-download the workspace and will discard any unsynced changes on it." That is exactly the confirmation such an action deserves.

MaxOfflineBeforeRetireSuggestion (default 90 days) only suggests retirement in the UI. Nothing auto-retires. Automatic retirement is how you lose someone's data while they are on parental leave.

The scale tension, and the dial that resolves it. At 4 devices, requiring the full non-retired roster to ack is reasonable. At 25 — the target tier — somebody is always behind, so a strict roster gate means compaction essentially never runs, the log grows without bound, and cold joins get monotonically worse. CompactionGate is therefore an explicit policy dial:

Value Floor requires Consequence
FullRoster (default at ≤8 members) acks from every non-retired member strongest; admin faces the retire dialog routinely at scale
MirrorsOnly (default at >8 members) acks from every advertised always-on mirror, plus ≥1 interactive device compaction stays regular; a straggler returning below the floor must full-resync and export unmerged work (§12.5)

The epoch-floor invariant holds identically under both — only the frequency of the unpleasant full-resync path changes. This is why user-owned always-on replicas move early in the roadmap: MirrorsOnly is only tolerable when mirrors exist and are current.

One mirror is enough, but it must be fresh. Requiring two mirrors would lock out every workspace where a single person runs a server — most of them, early on. Requiring one unconditionally lets a dead mirror strand the floor forever, with retiring live members as the only escape. So MirrorsOnly permits a single mirror but refuses to cut an epoch if that mirror has not acked within MirrorStaleAfter (default 7 days), and the workspace settings surface a standing warning that compaction has a single point of failure. The guard is against a stale mirror, which is the failure that actually happens, not a permanently dead one.

12.3 Snapshot format

A snapshot is a deterministic serialization of materialized state at the floor:

snapshot := header ‖ table_chunks…
header   := {workspace_id, epoch_id, floor_hlc, schema_version, schema_hash,
             membership_history (complete, never compacted),
             live tombstone set with hlc ≥ floor_hlc,
             attachment manifest (hash, size, refcount)}
table_chunks := for each table, rows in primary-key order, protobuf, 1 MiB chunks
snapshot_hash := BLAKE3 of the whole byte stream

Determinism is mandatory — two admins computing a snapshot for the same epoch must produce identical bytes, and localtest asserts it. It is stored in snapshots (chunked so a partial transfer resumes) and served over the snapshot stream.

Membership history is never compacted. It is small, it is the audit record, and it is what makes old ops verifiable.

12.4 Op compaction

After an epoch cut is applied, ops with hlc < floor_hlc are deleted from ops in batches (10 000 per transaction, with PRAGMA incremental_vacuum), except:

  • All auth.* ops (retained forever; they are the membership chain).
  • epoch.cut ops (retained forever; they form the epoch chain).
  • Ops referenced by an unresolved conflict record.

12.5 Joining or rejoining

peer HELLO with epoch_id != ours
  ├─ their epoch is an ancestor of ours
  │    → they need a full resync: send epoch chain + SNAPSHOT + ops since floor
  │    → they replace local materialized state, keep their own unsent ops:
  │        · ops with hlc ≥ floor_hlc → re-offered, applied normally
  │        · ops with hlc <  floor_hlc → REJECTED; exported to
  │          diagnostics/unsynced-<epoch>.json and surfaced as
  │          "N changes made while offline could not be merged" with a viewer
  └─ their epoch is unrelated (fork)
       → hard error, close(EpochFork). This is a bug or a restored-from-backup
         device; requires operator action (`allons compact --repair`).

The "changes could not be merged" path is unavoidable in a system that ever compacts. Making it visible, exportable, and rare (30-day floor) is the design; pretending it cannot happen is not.

12.6 Attachment availability

Metadata replicates in milliseconds; the 200 MB video it references lives on a laptop that just closed. Everyone else sees a placeholder that may never resolve. This is the most likely source of "the app is broken" reports at this tier, so availability is policy, not luck:

  • Default is a size threshold. Under EagerBlobMax (default 1 MiB), the blob is pushed to peers on commit — cheaper than the request round-trip. Above it, the blob is pushed only to peers advertising the blob-mirror feature, and fetched lazily by everyone else. No author decision required in the common case.
  • Override per model with +allons:blob replicate=eager|lazy|mirror-only when the default is wrong.
  • Mirroring is opt-in per always-on replica, with a declared byte budget advertised in HELLO. Nobody's disk gets volunteered by someone else's attachment, and a mirror at capacity advertises zero remaining rather than silently dropping writes.
  • Pinning is first-class. Workspace.Pin(ctx, scope) walks the attachment manifest and fetches everything in scope, with progress on local:blob-progress. This is what makes "local-first" true for a workspace with attachments rather than true-for-text-only, and it is the answer to boarding a plane. Unpinned, unreferenced blobs become eviction candidates under disk pressure well before the GC grace period below.

12.7 On-demand purge (the erasure guarantee)

Deletion is a tombstone: the row is flagged, the data stays in the log until compaction. That is the right default — it is undoable, it converges, and it costs nothing. It is also not erasure, and "user-owned data" implies a stronger promise than tombstones can keep.

Purge is a separate, admin-only action. data.purge{entity_ids, reason, requested_hlc} records an intent to erase. It does not itself delete anything; it makes the next epoch cut obligated to.

delete (any editor)  → tombstone → hidden, recoverable, still in the log
purge  (admin only)  → intent    → next epoch cut floors above it → gone

On an epoch cut, the floor must be at or above the delete HLC of every outstanding purge target, and the cut may be triggered on demand — this is the single case where MinCompactionAge (30 days) is waived, because the whole point is not waiting. Attachment hashes referenced only by purged entities bypass BlobGracePeriod and are unlinked in the same pass.

The guarantee this supports, stated precisely: removed from every replica that applies epoch E, and from any dormant replica the next time it syncs — because a replica below the floor must full-resync from a snapshot that no longer contains the data (§12.5).

What it cannot reach, and what any product claim must therefore exclude: a replica that never comes back online, an encrypted export a member took before the purge (§16.2), and any copy made outside the app. The promise is bounded by "replicas that continue to participate," not by wall-clock time.

Why the op kind ships in v1 even though the mechanism is phase 7. Op kinds are wire contract. A data.purge introduced later is an unknown kind to every earlier build, which quarantines it (§10.2) — so a purge issued into a mixed-version mesh would be honored by nobody, silently. Defining the kind from the first release means every replica records the intent from day one and honors it as soon as its build supports compaction.

12.8 Attachment GC

Mark-and-sweep, never synchronous with a delete:

  1. Reduction of a tombstone or a cover change decrements attachments.refcount.
  2. refcount = 0 ⇒ row inserted into blob_gc_candidates with a timestamp.
  3. An hourly sweeper deletes CAS files whose candidate age exceeds BlobGraceperiod (default 7 days) and whose refcount is still 0 and which no active transfer holds. Grace period covers the undelete case and the "concurrent add of the same hash" race.
  4. Startup sweeps blobs/tmp/ unconditionally and reconciles CAS files against attachments (orphan files → candidates; missing files for state=present rows → state=0, re-fetch).

Because the CAS is content-addressed, a double-add is a no-op and a delete-then-re-add is safe. The only true hazard — deleting a file another workspace still references — is handled by making refcount workspace-scoped and the CAS global with a summed refcount view.


13. Testing and simulation

13.1 The harness

package localtest

func NewReplica(t *testing.T, schema local.Schema, opts ...Option) *Replica
  // opts: OnDisk(dir), Memory(), Clock(skew), SchemaVersion(n), Role(auth.RoleEditor)

func (r *Replica) WS() *local.Workspace   // ← tests use the SAME repository API
func (r *Replica) App() *local.App

func Pair(t *testing.T, a, b *Replica)              // mutual enroll + connect
func Group(t *testing.T, rs ...*Replica)            // full mesh
func Partition(rs ...*Replica)                      // one-way or symmetric
func Heal(rs ...*Replica)
func Restart(t *testing.T, r *Replica)              // close + reopen from disk
func Skew(r *Replica, d time.Duration)
func Offline(r *Replica, d time.Duration)           // virtual time
func Revoke(t *testing.T, admin, subject *Replica)
func RelayOnly(rs ...*Replica)                      // no direct path
func Corrupt(mode CorruptMode) Option               // bitflip, truncate, oversize, replay

func EventuallyEqual(t *testing.T, rs ...*Replica)  // drives the sim, then compares
func AssertConverged(t *testing.T, rs ...*Replica)  // compares now, no driving
func Drain(t *testing.T, rs ...*Replica)            // run sim to quiescence

One scheduler rule, so nothing is ambiguous. Mutators — Partition, Heal, Skew, Offline, Restart, RelayOnly — change link state immediately on return. Nothing about them is queued. Delivery, retries, and anti-entropy only happen while the scheduler runs, and exactly two calls run it: Drain (until quiescent) and EventuallyEqual (until equal, quiescent, or the virtual deadline). So

localtest.Heal(alice, bob)
localtest.EventuallyEqual(t, alice, bob)

is correct with nothing between the lines: Heal reconnects, EventuallyEqual runs the simulation forward until the reconnected link has carried everything. AssertConverged deliberately does not drive the scheduler — it is for asserting that a state you already drained has not moved.

A failed convergence assertion explains itself. "Fingerprints differ" is the least useful sentence the harness could produce, and convergence failures are exactly the case where a developer has no intuition to fall back on. On success the output is one line; on failure:

--- FAIL: TestConcurrentTitleEdits (0.01s)
    replicas did not converge after 200 simulated seconds

    reproduce:  ALLONS_SEED=8813472 go test -run TestConcurrentTitleEdits

    diverged:   notes/01H8… title    alice="Alice"  bob="Bob"
                notes/01H8… tags     alice=[a b]    bob=[a]

    heads:      alice  alice:3 bob:2          pending: —
                bob    alice:2 bob:2          pending: alice:3   ← never applied

    scheduler:  quiescent
    last events: t=41.2 deliver alice→bob OPS(1) ok
                 t=41.2 bob rejected op 7a21…  reason=below_epoch_floor
                 t=40.0 heal(alice,bob)

    bundle:     /tmp/allons-test-8813472/  (dbs, logs, sim trace)

The diagnosis is usually visible in the output — here the link healed and the operation was delivered, but Bob rejected it against an epoch floor, so this is a compaction bug rather than a network one. Heads plus the event tail is the difference between reading that in ten seconds and bisecting for an hour.

Replica.WS() hands back an ordinary *local.Workspace, so tests call exactly what handlers call — no separate test DSL to keep in sync, and a passing test exercises the production path:

func TestConcurrentTitleEdits(t *testing.T) {
    ctx := context.Background()
    alice := localtest.NewReplica(t, gen.Schema())
    bob   := localtest.NewReplica(t, gen.Schema())
    localtest.Pair(t, alice, bob)

    note, err := notes.Of(alice.WS()).Create(ctx, notes.SetTitle("draft"))
    require.NoError(t, err)
    localtest.EventuallyEqual(t, alice, bob)

    localtest.Partition(alice, bob)

    _, err = notes.Of(alice.WS()).Update(ctx, note.ID(), notes.SetTitle("Alice"))
    require.NoError(t, err)

    _, err = notes.Of(bob.WS()).Update(ctx, note.ID(), notes.SetTitle("Bob"))
    require.NoError(t, err)

    localtest.Heal(alice, bob)
    localtest.EventuallyEqual(t, alice, bob)

    // Deterministic winner, not "whichever arrived last".
    got, err := notes.Of(alice.WS()).Get(ctx, note.ID())
    require.NoError(t, err)
    require.Equal(t, localtest.LWWWinner(alice, bob), got.Title())
}

13.2 The simulated network

localtest supplies a transport.Transport backed by a deterministic scheduler: virtual clock, seeded RNG, an event queue, and per-link fault configuration (latency distribution, drop rate, duplication rate, reorder window, MTU for datagrams, bandwidth cap). A failing test prints its seed; ALLONS_SEED=… go test -run … reproduces it exactly. No time.Sleep anywhere in the harness.

This is why the fault matrix is affordable — every scenario below runs in milliseconds with no OS networking and no CGO:

Fault Mechanism
Partition / heal link state in the sim net
Reordering per-link reorder window
Duplicates duplication probability, plus an explicit ReplayAll mode
Dropped datagram hints datagram drop rate = 1.0 (a whole test suite variant)
Delayed delivery latency distribution up to minutes of virtual time
Restarts Restart closes and reopens the real SQLite file
Clock skew per-replica offset injected into the HLC source
Long-offline peers Offline(r, 120*24*time.Hour) in virtual time
Revocation real auth.revoke ops through the real pipeline
Schema mismatch SchemaVersion(n) builds a replica with a restricted reducer set
Relay-only sim path type = relay, higher latency, no migration
Corrupt / malicious frames Corrupt wraps the link and mutates bytes

13.3 Property tests

  • Permutation convergence. Generate N ops, apply them to K replicas in K random permutations (with duplicates), assert identical Fingerprint(). This is the single most valuable test in the framework and it is generated automatically for every model.
  • Idempotence. Apply every op twice; fingerprint unchanged.
  • Snapshot equivalence. snapshot(apply(ops)) == apply(ops) after restore; and snapshot bytes are byte-identical across replicas at the same epoch.
  • Compaction safety. After a cut, replay the full pre-compaction op set into a fresh replica and assert the post-floor state matches.
  • Authorization monotonicity. No ordering of auth.* ops grants more authority than the sequential order would.

13.4 Fuzzing (go test -fuzz, corpora committed)

FuzzFrameDecode, FuzzEnvelopeVerify, FuzzInviteParse, FuzzReducerApply (per model, generated), FuzzSnapshotLoad, FuzzHLCPack, FuzzWantRanges. Each asserts: no panic, no unbounded allocation (checked with a memory limit), and — for reducers — that a successfully applied random op leaves the state fingerprint well-formed.

Everything runs under -race in CI, plus a nightly long-run of the sim with random seeds and a saved-seed regression corpus.

13.5 webtest — the other half

localtest covers replication. Most application bugs are not replication bugs, and handlers, fragments, and events deserve testing that is as ordinary as the convergence story:

package webtest

// New builds a real replica with the application's real routes, then creates
// and activates a workspace named "test" — the state a running app is in.
func New(t *testing.T, cfg local.Config) *Client
func (c *Client) NoWorkspace() *Client            // for testing the /welcome path
func (c *Client) AsRole(r auth.Role) *Client

func (c *Client) Partial() *Client                 // sets HX-Request-Type: partial
func (c *Client) Upload(field, filename string, r io.Reader) *Client
func (c *Client) Get(path string) *Response
func (c *Client) Post(path string, form url.Values) *Response

func (r *Response) AssertStatus(code int) *Response
func (r *Response) AssertFragment() *Response      // no layout, no <html>
func (r *Response) AssertFullPage() *Response
func (r *Response) AssertRedirect(to string) *Response
func (r *Response) Select(css string) *Node        // assertions against markup
func (r *Response) AssertEvents(topics ...string) *Response
func (r *Response) AssertNoEvents() *Response
func TestUpdateNoteRendersCard(t *testing.T) {
    c := webtest.New(t, cfg).Partial()

    c.Post("/notes", url.Values{"title": {""}}).
        AssertStatus(422).
        AssertFragment().
        Select("[data-error=title]").AssertText("Title is required")

    c.Post("/notes", url.Values{"title": {"draft"}}).
        AssertStatus(200).
        AssertEvents("local:notes-changed")
}

It runs against a real replica with the application's real routes, so workspace middleware, the bootstrap redirect, commit, projections, and event emission are all exercised rather than mocked. The pieces that specifically need this coverage — full-page versus fragment, validation fragments, the RequireWorkspace redirect, multipart handlers, and which events a mutation emits — are exactly the ones that break silently otherwise.


14. Diagnostics and observability

14.1 The five states, kept distinct

type Status struct {
    Network   NetworkState  // Offline | Reachable | RelayOnly | Direct
    Peers     []PeerStatus  // per peer: state machine state, path, RTT, last ack
    Local     LocalState    // Committed(n pending ops to send)
    Replication ReplState   // Isolated | Partial(x/y peers current) | Synced
    Storage   StorageState  // Healthy | Degraded(reason) | Recovering
    Workspace WorkspaceState// Active | UpgradeRequired | Quarantined
                            //   | EpochFork | GovernanceFrozen (§9.6)
}

The UI must never collapse these into a single green dot. "Locally committed" (your data is safe) is a completely different promise from "fully synchronized according to known peer heads" (everyone has it), and conflating them is how local-first apps lose user trust. The default /sync fragment shipped with allons new renders all five with plain-language copy:

✅ Saved on this device · 🔄 2 of 3 devices up to date · Dana's iPad last synced 4 days ago

"Fully synchronized" is defined precisely as: for every non-retired member m and every replica r, peer_acks[m][r].acked_seq >= peer_heads[r].contiguous_seq. It is a claim about known peer heads and the UI says so.

14.2 Logging

slog throughout, JSON to logs/app.jsonl (10 MB × 5 rotation), text to stderr in dev, and bridged to OTLP logs when export is configured (§14.3). Stable attribute names: workspace, replica, peer, op_id, kind, seq, hlc, epoch, reason_code. Every rejection path has a distinct reason_code constant — bad_signature, below_floor, unauthorized, unknown_kind, frame_too_large, schema_hash_mismatch — so support can grep one field. Peer identifiers are logged as the first 8 hex chars by default; full IDs at DEBUG.

14.3 Metrics and traces (OpenTelemetry)

Always available locally at /_allons/metrics as JSON with zero configuration. Export is OTLP, and the collector runs on the user-owned always-on replica by default — an org sees its own dashboards and nothing leaves its infrastructure. Vendor export is opt-in, and when enabled strips workspace IDs, replica IDs, entity IDs, and all payload-derived attributes; only counts, durations, and bucketed cardinalities cross the boundary. A local-first app that phones home contradicts its own premise, so the default is that it doesn't.

Three signals, mapped:

  • Logsslog records bridged to OTLP logs, same attribute names as §14.2, so reason_code is queryable rather than greppable.
  • Metrics — ops applied/rejected by reason, bytes in/out by frame type, sessions by state, handshake failures by cause, blob bytes transferred, WAL size, commit duration (p50/p99), reducer duration by kind, anti-entropy rounds, strikes, effect outbox depth and dead letters.
  • Traces — two spans worth having: a sync session (handshake → catch-up → live → drain, with WANT/OPS/ACK as child spans) and a commit (validate → reduce → project → append → fsync). The first explains why a peer isn't converging; the second explains why a keystroke feels slow. Both are far more legible than the equivalent log correlation.

Sampling is head-based and off for commits (they're cheap and low-volume), always-on for sync sessions (they're rare and expensive).

14.4 Diagnostics bundle

allons inspect bundle / in-app "Export diagnostics" produces a zip:

manifest.json      versions, build, platform, schema hash, protocol version
status.json        the Status struct above
peers.json         sessions, paths, RTTs, last ack, strike history
heads.json         our heads, peer acks, computed pending set
integrity.txt      PRAGMA integrity_check + foreign_key_check
schema.json        applied migrations + lock hashes
logs/app.jsonl     last 5 MB
conflicts.json     unresolved conflict records
quarantine.json    counts by (kind, schema_ver), no payloads

Redaction is the default and is structural: no op payloads, no blob contents, no titles or bodies, no full key material — only IDs, hashes, counts, and framework metadata. A --include-payloads flag exists, prints a loud warning, and is what a developer uses on their own machine.

14.5 The developer inspector

Diagnostics for users can wait for phase 8. Diagnostics for developers cannot: the framework becomes hard to reason about the moment replication exists, which is phase 3. So a dev-only inspector ships alongside the engine, mounted at /_allons/dev behind a build tag that excludes it from release binaries entirely.

It answers the six questions that come up while building:

Panel Shows
Last command the request, the operation it emitted, its op_id, HLC, and commit duration
Reduce / project which reducers and projections ran, in what order, how long each took
Queues pending change events (pre-coalescing) and the effect outbox with attempt counts
Peers each session's state machine position and why it is there — not "backoff" but "backoff 12s after handshake rejected: schema_hash_mismatch"
Quarantine stored-but-unapplied ops grouped by (kind, schema_ver) with the reason
Correlation the full chain for one request, below

The correlation chain is the panel that turns a vague report into a five-minute fix. For a single request ID it shows:

POST /notes/01H8…                                    req_9f4c
  ├ workspace   acme (active)
  ├ authorized  editor via cap granted 2026-02-11 by alice   → allow
  ├ repository  notes.Of(ws).Update(01H8…)
  ├ committed   op 7a21… kind=notes.update seq=alice:1204 hlc=…   3.1ms
  ├ reduced     notes (title, tags) · projection tag_facets 0.4ms
  ├ notified    local:notes-changed  entities=[01H8…] fields=[title,tags]
  │             suppressed in origin window (echo, §11.3)
  ├ effects     notify_mentions queued (OnLocalCommit)
  └ replicated  bob acked 41ms · carol pending (backoff 12s)

allons inspect --open [replica] open the inspector for a running dev replica; in-app shortcut is Cmd-Shift-I. dev` binds a real localhost port per replica for this; release builds compile the inspector out entirely.

allons explain <request-id|op-id>` prints the same chain from the terminal, so it is available in CI, over SSH, and from a diagnostics bundle rather than only in a browser.

allons dev --replicas N gains a fault control panel over the same vocabulary the simulator already speaks (§13.2): partition, heal, restart, clock skew, relay-only, and version mismatch as toggles across the running replicas. The mechanisms exist for tests; exposing them as buttons costs almost nothing and turns "reproduce the bug the user reported" into a thing you do with the mouse.

14.6 Error messages

Framework errors follow progressive disclosure: one sentence of cause, one of action. Identifiers, hashes, HLCs, and protocol details appear only under --verbose or in the diagnostics bundle.

$ allons package
error: schema has an unsealed draft migration
  Run `allons schema seal` before packaging — a draft produces a schema hash
  no other device can reproduce. (`dev` and `build` accept drafts.)

$ allons package --verbose
error: schema has an unsealed draft migration
  draft:         migrations/0007_draft.sql
  changes:       notes.Note +Tags []string orset
  app_schema:    3 (draft would become 4)
  schema_hash:   b3:9f4c…21ae (unstable while draft)
  Run `allons schema seal` before packaging.

The default form is what a developer needs at 4pm on a Thursday; the verbose form is what they paste into a bug report.


15. Native iroh-ffi packaging

Constraints: authors must never install Rust, Cargo, or UniFFI, and must never hand-edit linker flags. Simultaneously, modernc.org/sqlite keeps SQLite pure-Go, so iroh-ffi is the only C dependency in the entire build.

15.1 Layout

internal/irohffi/
  bindings.go                   // UniFFI-generated Go, vendored, never hand-edited
  cgo_darwin_arm64.go           //go:build darwin && arm64
  cgo_darwin_amd64.go
  cgo_linux_amd64.go
  cgo_windows_amd64.go
  lib/
    darwin_arm64/libiroh_ffi.a  // static archive, ~20-40 MB
    darwin_amd64/libiroh_ffi.a
    …
  checksums.json                // sha256 per artifact, verified at build time
  version.txt                   // upstream iroh-ffi tag
//go:build darwin && arm64

package irohffi

/*
#cgo CFLAGS:  -I${SRCDIR}/include
#cgo LDFLAGS: ${SRCDIR}/lib/darwin_arm64/libiroh_ffi.a
#cgo LDFLAGS: -framework CoreFoundation -framework Security -framework SystemConfiguration
#cgo LDFLAGS: -lresolv -lc++
*/
import "C"

Static archives, not dylibs. A .a links straight into the Go binary, which means: no @rpath fixups, no install_name_tool, no separate dylib to ship alongside, no DYLD_* surprises. The output is a single self-contained executable. The cost is binary size and a rebuild to update iroh; both are fine for a desktop app.

This is also what makes go install a viable distribution channel (§15.3) — a user compiling from source needs a C linker but never Rust, Cargo, or UniFFI. That consequence was not the original motivation for choosing static archives, and it ended up being the more valuable one.

15.2 Distribution of the artifacts

Committing 40 MB × 4 platforms to git is unpleasant. Instead:

  • A separate module allons.dev/irohffi-darwin-arm64 (etc.) contains the archive and its cgo file. The main module depends on all of them; Go's module cache handles download and integrity via go.sum. Only the platform whose build tags match is compiled.
  • CI publishes these modules from a reproducible Rust build of a pinned iroh-ffi tag, and records the build's SHA-256 in checksums.json and in the release notes.
  • allons doctor verifies checksums, CGO availability, the toolchain, macOS SDK, and — importantly — runs a live loopback connection test: it starts two endpoints in-process, dials one from the other over the real iroh stack, exchanges a HELLO, and reports the negotiated path. That single check catches ~90% of "it builds but nothing connects" reports.

15.3 Build and distribution

Distribution is brew and go install, not a notarized .app. No Developer ID certificate, no notarization round-trip, no stapling, no Sparkle appcast, no Apple developer account at all.

This is only possible because of §15.2: iroh-ffi ships as prebuilt static archives inside per-platform Go modules, so go install needs a C linker but never Rust, Cargo, or UniFFI. Had we vendored source or shipped dylibs, go install would have been a non-starter and notarization would have been the only realistic path.

The Gatekeeper argument, precisely. Quarantine is applied by the downloading application, not by the file itself. A binary compiled on the user's machine (go install, brew install --build-from-source) is never quarantined. A Homebrew formula — including a prebuilt bottle — installs into the prefix without setting the quarantine bit. A Homebrew cask applies quarantine by default, which is exactly why we ship a formula that installs a binary rather than a cask that installs a .app.

Channel Audience Needs
brew install example/tap/notes most users nothing — CI-built bottle
go install example.com/notes@latest Go developers, Linux Go, a C linker (Xcode CLT / build-essential), webkit2gtk-dev on Linux
direct binary download CI, air-gapped chmod +x, and xattr -d com.apple.quarantine if fetched by a browser

allons build — local, unsigned, fast:

  1. generate --check freshness. A draft migration is allowed here — only package and release refuse one (§6.4).
  2. allons protocol-check (fails on an unbumped incompatible schema change).
  3. wails build with CGO_ENABLED=1, MACOSX_DEPLOYMENT_TARGET pinned.

allons package — cross-compile the release matrix, strip, produce tarballs and a SHA256SUMS file.

allons releasepackage, then publish the artifacts and open a pull request against the Homebrew tap with the new version and checksums. Minutes end to end, with no external service in the loop that can reject it.

Three consequences worth stating plainly, since each gives something up.

No App Sandbox. Sandboxing requires a signed bundle with entitlements, so a brew-installed binary runs with ordinary user-level filesystem access. Containment comes from scope instead: the framework touches only DataDir, and file import/export goes through user-initiated dialogs. Shipping through the App Store would reinstate every requirement dropped here.

Second-class macOS presentation. A bare binary launching a WKWebView window works, but the menu bar shows the binary name, there is no dock icon from an Info.plist, and file associations do not work. Acceptable for a developer-audience tool; the moment the product wants a polished Finder-visible app, a cask and signing come back into scope.

Update integrity moves to Homebrew. The Ed25519 update key and signed appcast are gone. Integrity now rests on the SHA-256 in the formula plus the tap repository's own access controls, and updates happen through brew upgrade rather than an in-app updater. That is a weaker guarantee than a signed appcast — a compromised tap can serve a malicious binary — and it is the honest cost of this decision. Nothing about it touches workspace security: operation signing keys (§9.1) are unrelated and unaffected.

Universal binaries: lipo two wails build outputs; the arm64 and amd64 archives are separate modules, so nothing conditional is needed beyond the build tags.


16. Security and operational envelope

16.1 Limits (all configurable, all with safe defaults)

type Limits struct {
    MaxFrame            int   // 1 MiB
    MaxOpsPerBatch      int   // 256
    MaxOpPayload        int   // 256 KiB
    MaxBlobSize         int64 // 2 GiB
    MaxBlobChunk        int   // 256 KiB
    MaxWantSpan         uint64// 10_000
    MaxPeerSessions     int   // 32
    MaxStreamsPerPeer   int   // 6
    MaxInflightOps      int   // 1024 (bounded channel)
    HandshakeTimeout    time.Duration // 10s
    StreamIdleTimeout   time.Duration // 60s
    SessionTimeout      time.Duration // 30m without progress
    MaxClockSkewWarn    time.Duration // 5m
    MaxClockDriftReject time.Duration // 24h
    StrikeLimit         int   // 3 per session, 10 persistent
    MaxDecompressRatio  int   // 100
    PeerBytesPerHour    int64 // 256 MiB — receiver-side rate limit, per replica
    EagerBlobMax        int64 // 1 MiB — see §12.6
    MaxQuarantineBytes  int64 // 1 GiB — ceiling on stored-but-unapplied ops
}

// WorkspaceLimits is REPLICATED workspace policy, set by an admin via a signed
// `policy.limits` op. It must be identical on every replica — see the rule below.
type WorkspaceLimits struct {
    MaxBytesPerReplicaPerEpoch int64 // 16 GiB
    MaxOpsPerReplicaPerEpoch   int64 // 5_000_000
    MaxEntitySize              int64 // 4 MiB of CRDT state for one entity
    WorkspaceQuotaBytes        int64 // 0 = unlimited
}

Every limit is enforced at the outermost layer that can enforce it: frame size before decode, signature before payload decode, authorization before reducer, quota before disk write.

16.1a Quotas: friendly use, bounded reach

The threat model is friendly members who make mistakes, with hard ceilings that bound what a hostile one could do. Three layers, escalating:

1. Warnings (local, advisory). Because the log is per-replica sequenced, every replica deterministically computes how many bytes and ops each replica has authored in the current epoch — the accounting is free and needs no coordination. At 50% and 80% of the workspace ceiling the authoring device warns before the commit, and peers echo the author's consumption back in Status so the warning reaches them even if their own view is stale. Nothing blocks.

2. Rate limits (local, timing-only). PeerBytesPerHour throttles a peer's ingest. This can differ per device safely, because a throttle delays an operation — it never rejects one. A laptop on a metered connection can set it low without diverging from anyone.

3. Hard caps (replicated policy). WorkspaceLimits bounds per-replica bytes and ops per epoch, entity size, and total workspace size. Exceeding a cap quarantines the offending ops (applied=0, §10.2) rather than discarding them — the data is retained, the resource consumption stops, and raising the cap releases them on the next drain. Only when quarantine itself exceeds MaxQuarantineBytes do ops get rejected outright, with strikes.

The rule that makes this safe: anything that can cause an operation to be rejected or quarantined must be replicated workspace policy. Anything that only delays may be local configuration.

If Alice caps at 10 GB and Bob at 50 GB, an op Bob applies and Alice quarantines leaves their materialized state divergent while their logs agree — a partition by configuration, invisible to every convergence test that compares fully-synced replicas. WorkspaceLimits is therefore a signed policy.limits op, versioned like any other membership change, and a replica that has not yet learned of a limits change quarantines conservatively until it does. localtest includes a fault-matrix entry that runs replicas with deliberately mismatched local Limits and asserts convergence anyway.

Defaults are set far above observed use — roughly 100× a heavy user's p99 — so an honest member never encounters them and an offline member is never blocked from working locally. The caps exist to bound the blast radius of a compromised device in the window before revocation propagates, not to meter normal work.

Admin-signed per-member byte budgets remain rejected: they block a member near their limit whose admin is offline, which violates rule 1. Uniform workspace-wide ceilings achieve the containment without that failure mode.

16.2 Keys and data at rest

  • keys/ is 0700, files 0600. On macOS the private keys are additionally sealed with a Keychain-held symmetric key (kSecAttrAccessibleWhenUnlockedThisDeviceOnly), so a copied ~/Library folder does not yield a working replica identity.
  • Optional at-rest DB encryption via a page-level encrypted VFS with a Keychain-held key. Off by default (it complicates recovery); on by a single config flag. The CAS is encrypted with per-blob keys derived from the content hash and the workspace key when at-rest encryption is on.
  • Encrypted export/backup: allons export --workspace W --out backup.age writes a snapshot + post-floor ops + blob manifest + selected blobs, encrypted to a passphrase or an age recipient. Import validates every signature before touching state. This is the sanctioned "move to a new laptop" path, and it produces a new replica identity on restore (so two machines never share a replica_id — which would break seq monotonicity and is the one thing that genuinely corrupts a workspace).

16.3 Corruption recovery

PRAGMA quick_check at every startup (cheap) and full integrity_check weekly or on demand. On failure:

  1. Enter Storage: Recovering; the HTTP middleware serves a read-only explanation fragment rather than 500s.
  2. Attempt WAL recovery, then VACUUM INTO a fresh file.
  3. If materialized state is corrupt but ops is intact ⇒ rebuild from the log: truncate domain tables, replay the epoch's ops in HLC order. This is a first-class supported operation (allons doctor --rebuild), and it is only possible because reducers are deterministic and the log is the source of truth.
  4. If ops itself is corrupt ⇒ full resync from peers via snapshot, or restore from an encrypted export.

16.4 Shutdown

App.Close in order: stop accepting HTTP; stop the sync engine's accept loop; send Bye on live sessions and wait up to 2 s for outstanding ACKs; cancel transfers; transport.Close (iroh graceful shutdown so peers see a clean close rather than a timeout); flush the event bus; PRAGMA wal_checkpoint(TRUNCATE); close the DB; release the key material. Bounded by a 10 s deadline overall, after which we hard-close — a desktop app that hangs on quit is worse than one that skips a checkpoint.


17. CLI

allons new <name>              scaffold the smallest credible app — schema/ app/ views/
                               main.go, one model, one handler, one template, CI.
                               Writes files FIRST, then reports toolchain readiness
                               as advice. Blocking checks belong to dev and doctor.
      --templ                  use templ instead of html/template
      --minimal                no example domain

allons dev                     wails dev + Go reload + regenerate on save
      --replicas 3             TOTAL replicas, not additional peers. Auto-paired,
                               named and colored Alice/Bob/Carol, each showing its
                               data dir, with the §14.5 fault control panel.
      --fresh                  discard the dev session's databases first
      --session <name>         persistent named dev session (default: ephemeral)
      --keep-data              fail instead of resetting on a destructive draft (§6.4)
      --offline                start with the sync engine paused
      --verbose / --json       per-file generation, sync frames, peer transitions

allons generate ./...          schemas, repos, updates, codecs, reducers, migrations, fuzz
      --check                  fail if regeneration would change anything (CI gate)

allons schema seal             freeze the draft migration, assign its number, record its
                               hash in lock.json, bump app_schema_version (§6.4)
      --status                 show the current draft and what it would become

allons schema explain <field>  which CRDT kind fits, and what the alternatives cost:

  $ allons schema explain schema.Note.Tags
    suggested  orset
    why        []string — concurrent additions from two devices both survive
    instead    lww replaces the whole slice; the later write wins and the
               other device's additions are lost
    also       counter (numeric totals), tombstone (soft delete)

allons inspect                 endpoint id · ticket · paths · peers · heads · pending · conflicts
      ops --replica R --from N
      quarantine
      invites
      bundle -o diag.zip

allons doctor                  CGO · toolchain · native lib checksums · SDK ·
                               live loopback iroh connection test · DB integrity
      --rebuild                rebuild materialized state from the op log

allons test                    go test + a small generated convergence smoke test  (seconds)
      --full                   -race, the whole fault matrix, fuzz corpora       (minutes)
      --seed N --iterations N --fuzz 30s

allons check                   the one command for editors and CI: generation
                               freshness, protocol compatibility, go test, and the
                               fast convergence smoke test. Stable exit codes, --json.

allons build                   generate-check → local binary; drafts allowed
allons package                 cross-compile the matrix → tarballs + SHA256SUMS;
                               refuses an unsealed draft
allons release                 package → publish artifacts → PR the Homebrew tap
                               (no signing, no notarization — §15.3)

allons inspect --open [replica]
                               open the inspector for a running dev replica; in-app
                               shortcut is Cmd-Shift-I. `dev` binds a real localhost
                               port per replica for this; release builds compile the
                               inspector out entirely.

allons explain <request-id|op-id>
                               the §14.5 correlation chain from the terminal:
                               repository call, authorization decision, emitted
                               op, change topic, entities, peers that acked

allons protocol-check          diff schema/protocol against migrations/lock.json and the
                               last released version; explain each change as
                               compatible | requires-bump | forbidden, with the reason
                               and the suggested alternative (§6.4)

allons compact                 --dry-run (default): show floor, blockers, reclaimable bytes
                               --cut: emit epoch.cut (refuses if any non-retired member lags)
                               --retire <replica>
                               --gc-blobs

Short commands are fast commands. People run the shortest thing repeatedly, so the shortest thing must reward the habit: allons test finishes in seconds and allons build produces a runnable binary. The expensive work — the full fault matrix, fuzzing, cross-compilation, publishing — lives behind --full and behind separate verbs.

17.1 Targeting: which app, directory, replica, workspace

Every inspecting or repairing command needs the same four answers, so they resolve identically:

  1. A project-local manifest, .allons/dev.json, written by allons dev and allons new. Running allons inspect heads inside a project needs no flags.
  2. Explicit overrides--data-dir, --workspace, --replica, --session — with the same spelling on every command.
  3. An installed-app fallback when run outside a project: --app dev.example.notes resolves the real user data directory.

Ambiguity is an error, never a guess. Two workspaces open and no --workspace produces a list and a non-zero exit, not a coin flip.

Destructive commands name their target and back it up first. doctor --rebuild, compact --cut, and dev --fresh print the exact database path, what will change, and where the backup went, then require confirmation unless --yes is passed.

17.2 Output discipline

allons dev is quiet when things work and specific when they do not:

ready · http://localhost:34117 · 3 replicas (alice bob carol) · inspector /_allons/dev
schema changed · Note +Tags []string orset · generated 8 files
build failed · schema/note.go:17 · unknown CRDT kind "set"
                 valid kinds: id lww orset counter tombstone blob custom created updated

Per-file generation, sync frames, op IDs, and peer transitions go to --verbose, --json, or the inspector — never to the default stream. --json plus stable exit codes make the CLI usable from editors and CI.

Regeneration is atomic, last-known-good, and mtime-stable. Inputs are limited to schema/, queries/, projections/, effects/, and templates; output is written to a staging directory and swapped in only if every step succeeds. The swap replaces only files whose contents changed and leaves the rest untouched, including their mtimes — otherwise every save would invalidate gopls, go test caching, and any file watcher for the whole gen/ tree. A typo in schema/note.go therefore leaves the previously generated code intact and the running app alive — it prints the error and waits for the next save, rather than deleting a working build because you were mid-keystroke.

allons dev --replicas 3 deserves emphasis: the hardest part of building a local-first app is seeing the multi-device behavior. Three real replicas with real iroh endpoints on one machine, each in its own named window, turns convergence bugs from theory into something you watch happen.

The flag counts total replicas--replicas 3 gives three windows. When a fault scenario produces a divergence, the panel prints a one-line command that replays it deterministically in localtest.


18. Phased roadmap

Phase Scope Exit criterion
0. Skeleton (4 wk) local.Open/Start/Close, wailsapp.Run, store + goose migrations, draft-migration workflow (§6.4), workspace bootstrap (§11.1), Wails template, chi, web.Render/Handler/Bind, webtest (§13.5), event echo suppression (§11.3), bridge JS, allons new/dev/check/schema seal, the compile-checked examples/notes fixture, tutorial + Daily Allons A notes app with local CRUD, zero networking, htmx swaps working, -race clean; renaming a field twice produces one migration, not two; first launch reaches a create-workspace screen; every code block in the docs compiles in CI
1. Log & reducers (3–4 wk) envelope, canonical encoding, ed25519 signing, HLC, the four CRDTs, generator for schema/repo/update/reducer/codec/migrations, Commit transaction contract Permutation-convergence property test passes for the notes model; local writes are logged, signed, and reducible
1b. Read model (3–4 wk) projections (state- and op-derived), rebuild driver, effect outbox + dispatcher, sqlc integration with SELECT-only fencing, query-only Read() handles (§3.2a), dev inspector: last-command / reduce / queues panels (§14.5) A denormalized projection and an activity feed both rebuild from a snapshot; an OnLocalCommit effect survives a kill -9 between commit and dispatch
2. Simulation first (2 wk) localtest with the deterministic sim transport, fault matrix, fingerprint (write model only), seeded reproduction; million-op batched-apply benchmark The brief's convergence test passes against a simulated network. No iroh yet. Catch-up throughput measured against the single-writer connection.
3. Iroh transport (3 wk) transport over iroh-ffi, ALPN, dial/accept, streams, tickets, address lookup, path reporting, doctor loopback test, vendored native modules, inspector peers + quarantine panels, dev --replicas fault controls Two real desktop builds on a LAN converge; then two across NATs via hole punching; then relay-only. A rejected handshake says why in the inspector, not just "backoff".
4. Protocol hardening (3 wk) HELLO negotiation, WANT/OPS/ACK, snapshot-first join (mandatory at this tier), anti-entropy, backpressure, strikes, limits, rate limiting, fuzz corpora, datagram hints (optional-by-construction) Full fault matrix green over the real transport; all-datagrams-dropped suite green; cold join of a 1M-op workspace completes over a relay
4b. Always-on replica (2 wk) headless allons serve build of the same binary, user-owned, blob-mirror feature with declared budget, opt-in mirroring A workspace with two member-run mirrors syncs while every interactive device is closed
5. Identity & authz (3–4 wk) capability certs, membership ops, invites, enrollment, roles, revocation + re-evaluation pass, key rotation, ownership transfer, membership_history Revoke-a-stolen-device scenario passes including retroactive re-evaluation; forwarded-op verification after author disconnect passes
6. Attachments (2–3 wk) BLAKE3 CAS, staged writes, BLOB_WANT/CHUNK, resume, refcounting, GC, /_allons/blob/ serving, size-threshold eager push, pinning UI Crash-injection tests during every stage of the staged write leave no inconsistent state; a pinned workspace opens fully offline with every attachment present
7. Lifecycle (3–4 wk) snapshots, epochs, compaction, retirement, full resync, quarantine drain, mixed-version matrix, on-demand purge honoring v1-recorded data.purge intents, compact/protocol-check A replica offline across a compaction rejoins correctly and its below-floor changes are exported, not silently lost; a purge issued in phase 1 is honored by the first cut here
8. Ship (3 wk) diagnostics bundle, OTLP metrics/traces, integrity/rebuild, encrypted export, at-rest encryption, package/release, Homebrew tap + bottles, go install verification on a clean machine, cookbooks brew install on a machine that has never seen Go or Rust, then pair by invite link and use offline for a week
9. Optional Linux/Windows, zstd feature, app-defined ephemeral presence payloads, signed byte budgets (only if §16.1 accounting shows they are needed)

Documentation is four layers, and they do not all ship together. A correctness specification is the wrong artifact to hand someone on their first afternoon, and the wrong place to look up how to attach a file:

Layer Ships in
Ten-minute tutorial — staged honestly against what exists: one local window at phase 0, two simulated replicas in a test at phase 2, two real windows syncing at phase 3 phase 0, extended each phase
Daily Allons — schema, handlers, queries, migrations, tests; the 90% guide phase 0, revised each phase
Cookbooks — attachments, invitations, projections, effects, recovery, compaction with the feature each covers
This document — internals, rationale, the reasoning behind every constraint continuously

The first two belong in phase 0 because they are design instruments, not deliverables: if the daily guide cannot explain the authoring workflow compactly, the API is not finished. Writing the tutorial against a half-built framework is how the first-launch bootstrap gap in §11.1 would have been found in week two instead of in review.

Phase 2 before phase 3 is the load-bearing sequencing decision. Building the simulator before touching iroh means every subsequent phase lands with the fault matrix already green, and it means a broken native build never blocks correctness work.


19. Risks, rejected alternatives, and open questions

19.1 Principal risks

Risk Severity Mitigation
iroh-ffi API churn or a regression in the Go bindings high transport is the only importer, behind an interface; the whole engine tests without it; the iroh version is pinned per release with a checksummed artifact; doctor loopback test is a canary
Reducer non-determinism sneaks in (map iteration, time.Now(), float, locale collation) high generated reducers only; a lint that bans time., rand., map range, and float in generated reducer files; permutation property test per model; custom reducers must pass the same test
HLC/wall-clock abuse — one device with a far-future clock wins every LWW forever medium reject beyond MaxClockDriftReject; warn beyond MaxClockSkewWarn; surface "this device's clock is wrong" in the UI; HLC persisted so restarts cannot regress
Compaction eats unsynced work medium 30-day minimum floor; requires acks from the full non-retired roster; retirement is explicit and confirmed; below-floor ops exported, never silently dropped
Invite links are bearer capabilities medium 24h default expiry, reader default role, every enrollment audited with its invite_id, revocable, and the docs say "treat like a password" in the first paragraph
Retroactive revocation is only eventually consistent medium bounded by capability expiry; re-evaluation pass on learning a revoke; honest UI language ("Mallory's device will stop being trusted the next time each of your devices syncs")
Wails v2 WebView differences across platforms medium macOS-first; the UI is plain HTML over HTTP with no exotic APIs; the bridge is 40 lines
htmx v4 is new low-medium usage is confined to local/web plus documented attributes; the app degrades to full-page loads if htmx fails to load, because every handler renders a real page
SQLite single-writer contention under sync bursts low-medium one writer connection, batched op application (up to 256 ops per transaction), separate read pool, WAL; measured in the sim with a 100k-op catch-up benchmark
modernc SQLite performance vs cgo SQLite low benchmarked per release; a build tag switches to mattn/go-sqlite3 if a workload needs it
Binary size (~40 MB of Rust + Go runtime + WebView glue) low acceptable for desktop; documented

19.2 Rejected alternatives

Rejected Why
iroh-docs / iroh-gossip / iroh-blobs Excluded by the brief, and the exclusion is right: they would own replication, authorization, and storage semantics that the application must own. Our operation log needs app-defined authorization at op granularity and app-defined compaction; a general document store gives neither. We reimplement roughly 2 000 lines to keep 100% of the semantics.
OPFS / IndexedDB / any browser storage The WebView is a rendering surface, not a database. Two storage engines means two truths.
WebSockets or SSE inside Wails A server-push channel already exists (Wails events). Adding a socket means a listening port, an origin story, a reconnect state machine, and a second lifecycle — for zero capability gain.
Automerge / Yjs via CGO or WASM Excellent CRDTs, but they bring a second runtime, opaque binary state that SQL cannot query, and rich-text semantics we explicitly do not need in v1. Four hand-rolled types that map to indexed SQL columns are far easier to reason about, debug, and compact.
A generic triple/EAV store for CRDT state Would eliminate migrations, but destroys query plans, indexes, and the ability to read your own database. The generator makes real columns cheap.
JSON or CBOR wire format Canonical byte-for-byte serialization is a security requirement (signatures), and protobuf with a frozen field order plus deterministic marshalling gives it with less ceremony than canonical-JSON rules.
Vector clocks for causal delivery Our reducers are commutative, so causal ordering buys nothing but metadata growth proportional to replica count. Per-replica sequences plus HLC are sufficient and compact.
Auto-retiring long-offline peers Deletes an absent person's data on a timer. Retirement is always an explicit, replicated, confirmed admin act.
A "sync server" with authority Reintroduces the thing local-first exists to remove. The optional always-on replica runs the identical binary and protocol, holds an ordinary editor capability, and can be removed at any time without ceremony.
Sharing one identity key across the user's devices Simplifies enrollment, destroys revocation. Per-device keys are the whole reason lost-device recovery is possible.
Range-digest anti-entropy Solves scattered gaps. With reliable streams and gapless per-replica sequences, a peer's missing set is almost always one contiguous range per replica — even at 30 replicas that is 30 range requests. Real complexity for a problem this design does not have.
Down-migrations Reversing an additive migration is trivial and pointless; reversing a destructive one loses data, and the "round-trip is lossless" test is impossible to write honestly. Additive-only migrations make downgrade a non-event instead (§5).
Dylib packaging for iroh-ffi @rpath fixups, separate signing, and a class of runtime load failures that a static archive simply does not have. It would also have made go install impossible, which turned out to be the decision that mattered most.
Code signing, notarization, signed appcast An Apple developer account, an expiring certificate, and a network round-trip that can reject a release — to serve an audience installing via brew or go install, neither of which triggers Gatekeeper. Costs are named in §15.3. Revisit for the App Store or a Finder-download audience.

19.3 Open items

Two decisions are deliberately unmade. Both ship with a default and with the instrument that will settle them, and neither blocks phase 1.

CompactionGate threshold — currently 8 members. Ships as a workspace setting rather than a constant, so retuning needs no release.

Instrument Reads on
allons.compaction.blocked (counter, by gate, member_count) how often a roster gate stalls
allons.compaction.stalled_days (histogram) how badly it stalls
allons.resync.full (counter, by reason) the cost of the permissive gate
allons.resync.unmerged_ops (histogram) the harm metric — work actually lost

FullRoster holds while stalled_days p90 stays under ~7 days; where it crosses, that member count is the threshold. unmerged_ops outweighs stall time — a blocked cut is an annoyance, lost work is not.

Sub-question still open: whether MirrorsOnly should require two mirrors. Safer, but it excludes every workspace where one person runs the only server — which is most of them at first.

Connection multiplexing across workspaces. One connection per (peer, workspace) stands.

Instrument Reads on
allons.workspaces.active (gauge) how many workspaces a real user holds
allons.connection.redundancy (gauge) connections ÷ distinct remote endpoints
allons.handshake.rate, allons.holepunch.attempts the cost being paid

Build it when connection.redundancy p95 exceeds ~3. Additive behind a features flag, so deferring costs nothing.

On sample size. Early on there will be a handful of installs, and opt-in telemetry from a handful of installs is not a distribution. Both thresholds should be set from a few design-partner deployments with explicit consent — the instruments exist to make those conversations concrete, not to replace them.


20. Summary of the load-bearing decisions

  1. One reducer for local and remote writes, in one SQLite transaction — the single most important invariant in the system.
  2. A narrow write model and a wide-open read model. Op kinds and CRDT semantics are a wire contract and stay conservative; projections cost nothing in protocol compatibility and are free to be anything. Conflating the two is what makes local-first frameworks feel constraining.
  3. op_id = content hash, making duplicate delivery free and forwarding verifiable without the author.
  4. Replica identity proven in HELLO over a channel binding, not inferred from the iroh EndpointId — so endpoints rotate freely and connection auth ≠ workspace auth.
  5. Membership is replicated operations with a permanent history, evaluated at op HLC — the only way forwarded ops stay verifiable.
  6. One anti-resurrection rule (hlc < epoch.floor_hlc ⇒ reject) from which all of tombstone retention, compaction safety, and long-offline rejoin follow.
  7. Compaction gated on durable acknowledgement, never on who is currently connectedFullRoster at small scale, MirrorsOnly once user-owned always-on replicas exist, with explicit and confirmed retirement either way.
  8. Quarantine-and-forward for unknown schema versions, which turns an out-of-date peer from a liability into a relay.
  9. Additive-only migrations, which turn app downgrade from an undefined failure into a non-event.
  10. Effects run from a transactional outbox with an explicit scope, and there is no leader — exactly-once is a configuration flag on a machine someone owns, not an election.
  11. The transport is an interface and the simulator comes before iroh, so correctness work never blocks on a native build.
  12. Datagrams carry only hints, with a whole test suite that runs with every datagram dropped.
  13. Generation, not reflection — the framework's behavior is Go you can read, diff, and step through.