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
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.
- 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.
- 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.
- 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.
- 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.
- 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.
Everything after this section is rationale and internals. This is the whole job, grouped by how often you actually do it.
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.
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).
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.
┌─────────────────────────────────────────────────────────────────────────────┐
│ 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.
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
| 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.
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.
// 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 hatchMulti-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 noctx. 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.
// 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 hereA 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:
- The interface has no
Exec—ws.Read().ExecContext(...)does not compile. - The read pool opens connections with
PRAGMA query_only=ON— a write reaching them through any path fails at the driver. - The generator rejects non-
SELECTsqlc queries at build time (§5).
Unrestricted handles stay internal to local/store. Nothing in the public API hands out a write-capable *sql.DB.
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.
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.
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 partMalformed 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.
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, …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() []PeerStatusOpen/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) errorfunc 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 *App — CreateWorkspace, 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.
// 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// 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.
{{/* 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}}//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.
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.
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) Predicatenotes.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.
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.
- Generated code depends only on
local/*,schema/, anddatabase/sql. It never imports the author's handlers. - Migrations are generated but immutable once released.
allons generatediffs 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 inmigrations/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_versionis 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 inschema/note.go, next to the struct whose semantics it defines. Custom reducers must pass the framework's determinism property test (§13.4) orallons testfails, and the determinism lint — notime.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.gocarries 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 --checkin 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..gitattributesmarksgen/**aslinguist-generatedso 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:
- Every sqlc query must be a
SELECT. AnINSERTorUPDATEthrough 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. - Queries may target projections and views, not raw CRDT metadata columns. Reading
title_hlcin 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.
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.
-- ---------- 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_versionFor 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.
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.
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 generaterewrites it in place rather than appending. -
Drafts are fine locally; they just cannot be distributed.
allons devandallons buildboth work with a draft, because needing a standalone binary before you are ready to freeze a schema is normal.allons packageandallons releaserefuse, 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 3runs, and exactly what you want — experimentation converges among itself and cannot poison a real workspace. -
allons schema sealfreezes the draft, assigns the real migration number and name, records its SHA-256 inlock.json, and bumpsapp_schema_versionif 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-datafails the regeneration instead of resetting, for when the dev database holds a reproduction you care about. -
allons protocol-checkexplains 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.
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-checkA 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.
// 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_idis a content hash ⇒ duplicate delivery is free (INSERT OR IGNOREon the primary key).- The signature covers
seq,hlc,epoch_id, andschema_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.
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.
| 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.
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).
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.
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.
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; }| 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.
┌──────────┐
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_seq⇒Want{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.
- 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 withBusy. - 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 withslogatWARNwith the peer'sreplica_idand 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.
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.
| 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.
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 adminAuthorization 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.
Membership is the one place where "last writer wins" is dangerous. Rules, in order:
- Revoke beats grant at any HLC ≤ the revoke's
effective_hlc. Concurrent grant+revoke ⇒ revoked. - Lower role wins on concurrent grants to the same subject.
- Ownership is a chain.
Transfermust carry a signature from the current owner. Two concurrent transfers fork the chain; resolution is (longest chain, then lowestop_id). See §9.6 — the fork itself is the smaller problem. - Admins cannot revoke the owner. Enforced structurally in the policy.
- Expiry is evaluated at op HLC, not wall clock. A capability that expired yesterday still validates ops authored the day before.
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
Revokewitheffective_hlc < now, the engine runs a re-evaluation pass: it scansopsfor the subject withhlc ≥ effective_hlc, marks themapplied = 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.
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_usesis 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_historywith theinvite_id, so "who let this device in" is always answerable. allons inspect inviteslists live invites; revoking an invite is aRevokeoninvite_pub, which invalidates future enrollments.
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_resolvedsigned 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.
Two independent negotiations in HELLO.
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).
| 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.
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 routesOn 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 new → dev → 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.RequireWorkspaceredirects rather than erroring. For a partial request it emitsHX-Redirect; for a full request, a 303. The scaffold fromallons newwires/to/welcomewhenActive()reports false.SetActivereturns 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.
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.
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.
An operation with
hlc < epoch.floor_hlcis 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.
Only an admin may emit epoch.cut, and the framework refuses to build one unless:
- Every non-retired member's
peer_acks.acked_seqcovers 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. - The proposed floor is at least
MinCompactionAge(default 30 days) old. - 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.
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.
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.cutops (retained forever; they form the epoch chain).- Ops referenced by an unresolved conflict record.
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.
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 theblob-mirrorfeature, and fetched lazily by everyone else. No author decision required in the common case. - Override per model with
+allons:blob replicate=eager|lazy|mirror-onlywhen 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 onlocal: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.
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.
Mark-and-sweep, never synchronous with a delete:
- Reduction of a tombstone or a
coverchange decrementsattachments.refcount. refcount = 0⇒ row inserted intoblob_gc_candidateswith a timestamp.- 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. - Startup sweeps
blobs/tmp/unconditionally and reconciles CAS files againstattachments(orphan files → candidates; missing files forstate=presentrows →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.
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 quiescenceOne 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())
}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 |
- 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.
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.
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() *Responsefunc 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.
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.
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.
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:
- Logs —
slogrecords bridged to OTLP logs, same attribute names as §14.2, soreason_codeis 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).
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.
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.
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.
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.
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.
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 viago.sum. Only the platform whose build tags match is compiled. - CI publishes these modules from a reproducible Rust build of a pinned
iroh-ffitag, and records the build's SHA-256 inchecksums.jsonand in the release notes. allons doctorverifies 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.
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:
generate --checkfreshness. A draft migration is allowed here — onlypackageandreleaserefuse one (§6.4).allons protocol-check(fails on an unbumped incompatible schema change).wails buildwithCGO_ENABLED=1,MACOSX_DEPLOYMENT_TARGETpinned.
allons package — cross-compile the release matrix, strip, produce tarballs and a SHA256SUMS file.
allons release — package, 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.
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.
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.
WorkspaceLimitsis therefore a signedpolicy.limitsop, versioned like any other membership change, and a replica that has not yet learned of a limits change quarantines conservatively until it does.localtestincludes a fault-matrix entry that runs replicas with deliberately mismatched localLimitsand 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.
keys/is0700, files0600. On macOS the private keys are additionally sealed with a Keychain-held symmetric key (kSecAttrAccessibleWhenUnlockedThisDeviceOnly), so a copied~/Libraryfolder 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.agewrites 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 areplica_id— which would breakseqmonotonicity and is the one thing that genuinely corrupts a workspace).
PRAGMA quick_check at every startup (cheap) and full integrity_check weekly or on demand. On failure:
- Enter
Storage: Recovering; the HTTP middleware serves a read-only explanation fragment rather than 500s. - Attempt WAL recovery, then
VACUUM INTOa fresh file. - If materialized state is corrupt but
opsis 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. - If
opsitself is corrupt ⇒ full resync from peers via snapshot, or restore from an encrypted export.
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.
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.
Every inspecting or repairing command needs the same four answers, so they resolve identically:
- A project-local manifest,
.allons/dev.json, written byallons devandallons new. Runningallons inspect headsinside a project needs no flags. - Explicit overrides —
--data-dir,--workspace,--replica,--session— with the same spelling on every command. - An installed-app fallback when run outside a project:
--app dev.example.notesresolves 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.
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 updatedPer-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.
| 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.
| 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 |
| 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. |
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.
- One reducer for local and remote writes, in one SQLite transaction — the single most important invariant in the system.
- 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.
op_id= content hash, making duplicate delivery free and forwarding verifiable without the author.- Replica identity proven in HELLO over a channel binding, not inferred from the iroh EndpointId — so endpoints rotate freely and connection auth ≠ workspace auth.
- Membership is replicated operations with a permanent history, evaluated at op HLC — the only way forwarded ops stay verifiable.
- One anti-resurrection rule (
hlc < epoch.floor_hlc⇒ reject) from which all of tombstone retention, compaction safety, and long-offline rejoin follow. - Compaction gated on durable acknowledgement, never on who is currently connected —
FullRosterat small scale,MirrorsOnlyonce user-owned always-on replicas exist, with explicit and confirmed retirement either way. - Quarantine-and-forward for unknown schema versions, which turns an out-of-date peer from a liability into a relay.
- Additive-only migrations, which turn app downgrade from an undefined failure into a non-event.
- 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.
- The transport is an interface and the simulator comes before iroh, so correctness work never blocks on a native build.
- Datagrams carry only hints, with a whole test suite that runs with every datagram dropped.
- Generation, not reflection — the framework's behavior is Go you can read, diff, and step through.