Fast, cost-efficient, good: pick all three with rastrillo.
The web framework companion to CARLOS: libraries you already know โ GORM, chi, html/template โ on a middle layer that gets sessions, CSRF, and owner scoping right, shipped to a platform that only bills you when someone shows up.
The opening statement: what a real multi-user app costs to write
written, in examples/notes โ accounts, sessions, owner-scoped CRUD, a background export
270 lines
proving Bob can never read Alice's notes โ or her bookmarks, or her export โ the isolation suite, in CI
1,177 lines
client-side JavaScript required, new DSLs, config formats
๐ค This page was written by an LLM, on the ideas, instruction, and editing of humans. AI-written text here is always marked, visibly, like this โ that's factor X.
Boring machinery, deliberately
Libraries you already know, SQLite, and files you can read โ nothing decided behind your back.
Libraries you already know
Models are plain GORM structs, routes are a chi router, pages are html/template โ no bespoke DSL to learn, and nothing your Go experience (or your model's training data) doesn't already cover. errors.Is(err, gorm.ErrDuplicatedKey) just works. Rastrillo supplies the parts that are hard to get right twice, not a new language for the parts that aren't.
The security defaults
Sessions are SQLite rows โ sign-out and revocation are real, not a cookie hoping to be forgotten โ with __Host- cookies on https and step-up re-auth (RequireFresh) for sensitive routes. CSRF is an origin check on every mutating request; no tokens to mint or forget. Failed sign-ins are rate-limited per account. And owner scoping has one contract: a row that isn't yours is a row that doesn't exist โ 404, never 403 โ enforced in examples/notes by a two-user isolation suite that runs in CI.
A 15KB contract, not 150KB of source
SKILL.md at the repo root is the whole app story โ the file shape, the scoping rules, the mass-assignment rule, the identity plugins โ budgeted under 15,000 bytes and reviewed like code. It's what an agent loads instead of the framework source, and what a person reads first. The docs are part of the artifact, not an afterthought.
Platform-ready by default
rastrillo.Resolve and Serve speak the CARLOS platform's activation contract end to end โ hibernating instances, carlos-app@ systemd unit tenants, plain instances, and a bare dev fallback, no separate integration per deployment shape. Hibernation is the cost-efficiency receipt: your app isn't running when nobody's knocking, and the platform wakes it when someone does.
One binary, no toolchain sprawl
go build with CGO_ENABLED=0 gets you the whole app โ SQLite compiled in via pure-Go modernc.org/sqlite under an owned GORM dialector, a writer pool capped at one connection and a reader pool that keeps WAL reads flowing. No bundler, no codegen service, no runtime to babysit, and not one line of client-side JavaScript by default.
Or declare it: fifteen lines in, screens out
Generated, not interpreted โ the declarative path beside the code path: mix them per resource, move a resource between them freely, use either or both.
declared manifest/ticket_types.toml โ the whole file
name = "ticket_types"route = "/admin/ticket_types"store = "exclusive"[list]columns = [{ field = "Name" }, { field = "Price", kind = "money" }, { field = "Status" }]search = true[[list.filters]]field = "Status"values = ["draft", "on_sale", "sold_out"][form]basics = [{ name = "Name", required = true }, { name = "Price", kind = "money", required = true }, { name = "Status" }]advanced = [{ name = "MaxPerOrder" }]
generated what rastrillo generate wrote from it
files committed under gen/, diffable in review like any code
25
lines of plain Go, SQL, HTML, and locale keys โ each action opens with one form.Parse call, the same documented helper a hand-written handler uses
1,202
actions, wired into the router โ list, create, show, edit, and the delete flow
9
screens โ a list with search, a working Status filter and paging; a two-tier form; a show page; a delete confirm
4
typed store โ schema, additive migrations, sqlc queries
1
TOML parsed at request time
0
required = true is server-side: the generated actions 400 with the field's own message, re-rendered in the form. There is no actions/ directory in this app at all โ examples/tickets is manifest-only, and it's the framework's own regression suite. Add scope = "user" and every generated query is owner-filtered by the signed-in subject โ someone else's row answers 404, the same contract the hand-written path enforces; examples/notes runs a declared, scoped resource beside hand-written handlers under one two-user isolation suite. And when a generated screen stops being enough, eject it: the file becomes yours, and the generator leaves it alone.
Deployedlive ยท real edge
Not a mockup
Rastrillo's example app is deployed on a real CARLOS platform right now: real S3-backed storage, a real edge, a real certificate, a hibernating instance that wakes when you knock. hello.bdf.oncarlos.com โ the same framework you'll have running locally two minutes from now, actually shipped (/api/version names the exact commit). This site is served the same way.
Run it locally
Nothing to install but Go: clone, run, and try to break the isolation yourself.
1
Clone the repo
git clone https://github.com/rastrilloorg/rastrillo && cd rastrillo/examples/notes
examples/notes is the front-door example: accounts, sessions, CSRF, flash, and owner-scoped notes โ 163 lines of domain code.
2
Run it
CGO_ENABLED=0 go run ./cmd/notes -addr localhost:8080
Visit localhost:8080 โ sign up, write a note. Open a private window, sign up as someone else, and try to reach the first account's note by URL: 404. That isolation is the framework's owner-scoping contract, and the same check runs as a test suite in CI.
3
Start your own
go install amadan.net/rastrillo/rastrillo/cmd/rastrillo@latest
rastrillo new myapp && cd myapp && go mod tidy && go test ./...
The scaffold is the same five-file shape, tests passing before you write a line. Then read SKILL.md โ the whole app story in one file. Building with an agent? That file is exactly what it should load.
Deploy it for real
CARLOS is self-hostable โ one binary, a bucket, and DNS. No PaaS account to sign up for.
GOOS=linux GOARCH=arm64 go build -o myapp-binary ./cmd/myapp
3
Ship, promote, route
carlos ship -app myapp -version $(git rev-parse --short HEAD) myapp-binary
carlos promote -app myapp <version> stable
carlos add -app myapp -kind instance myapp.yourdomain.com
The same sequence โ against a real S3-backed bucket, a real edge, a real certificate โ that put rastrillo's own example live at hello.bdf.oncarlos.com. Standing up the box, bucket, and DNS is the one-off part; from then on every deploy is ship then promote.
What's real today
A young framework that ships its status on its homepage โ this list moves when the work lands, not before.
Posted
The middle layer: db (GORM on cgo-free SQLite, split writer/reader pools, translated constraint errors), sessions (revocable rows, step-up RequireFresh), csrf, flash, form, view, scope โ each importable alone, tested alone
rastrillo/auth โ the family default sign-in: magic-link email that auto-upgrades to Keymail when the address has a claimed inbox, over keymaildev/signin
rastrillo/password โ classic email+password on the sessions core: stdlib PBKDF2, per-account rate limiting, one non-oracle failure message
SKILL.md โ the authoring contract, under 15,000 bytes, reviewed like code: what an agent loads instead of the framework source
examples/notes โ accounts, sessions, CSRF, flash, owner-scoped CRUD, and a background export in 270 domain lines, with a two-user isolation suite in CI
rastrillo/crypto โ the family envelope: P-256 sealing and signing, invite derivation and key wrapping, a WebCrypto JS twin โ all pinned by golden vectors from real consumers
rastrillo/webauthn โ passkeys, verify-only, with a test authenticator and the browser module included
rastrillo/eventlog โ the mergeable store shape: append-only streams, pure Derive, deterministic merge pinned by vectors
rastrillo/blobs โ content-addressed bytes on the platform's object store, presigned URLs, sealed E2EE wrapper
rastrillo.Resolve/Serve/Run โ the full platform activation contract, hibernate and systemd tenants included
Localization: locale-resolved routes, T/Tf with fallback, a ship-gate catalog check
rastrillo/ui โ the component vocabulary: 27 partials, documented class idioms, design tokens โ light and dark, WCAG AA enforced by tests, zero JavaScript
The manifest path: a declared Resource generates store, actions, screens, and locale keys โ an optional, equal alternative to hand-written handlers, mixed per resource; generated actions compose the same documented form.Parse/view helpers a hand-written app uses; examples/tickets is its regression suite
Manifest resources on the mergeable store (v0.16.0) โ store = "mergeable" generates the same screens over an eventlog-backed store: each record one append-only stream, deletes appended as tombstones, every read derived by replaying the merged history; examples/tickets' announcements resource is the generated proof, tombstone test included
Manifest resources with per-user scoping โ scope = "user" owner-filters every generated query by the session subject, so someone else's row answers 404, the same contract the hand-written path enforces; examples/notes mixes a declared, scoped resource beside its hand handlers, proven by one two-user isolation suite
rastrillo new โ scaffolds the middle-layer five-file shape with a passing test suite out of the box; the declarative path stays one TOML away (manifest/README.md carries the mounting recipe)
rastrillo/passkey โ the WebAuthn second factor, both places it belongs: on the step-up seam (enroll while signed in, satisfy RequireFresh with an assertion instead of a full re-sign-in) and at first sign-in (Gate on the identity plugins' SecondFactor hook: a verified first factor becomes a pending half-session only an assertion completes โ the minted session names both factors) โ single-use, subject-bound challenges and half-sessions throughout. And the escape hatch (v0.14.0): ten single-use recovery codes, minted behind step-up and shown once, redeem a lost passkey at the gate by plain form POST โ no JavaScript, because that's exactly when WebAuthn isn't working; sign-in only, so step-up still takes a real assertion
Agents: actions opt in as tools; the generated registry dispatches consent-gated, actor-attributed calls; sidecars speak the platform contract
Background work made observable โ jobs gives a goroutine an owner-scoped handle and a status page that works with scripts off: a noscript refresh while it runs, a 303 to the result when it's done. The one script in the framework is rastrillo.js, a ~130-line app-owned shim that polls HTML fragments for the smooth version โ no JS required, htmx a choice rather than a dependency; examples/notes ships a background export under the same isolation suite. Bounded, too (v0.13.0): an owner holds at most four running jobs, and a stuck one is failed after fifteen minutes, its slot freed. And pushed (v0.15.0): status pages ride Server-Sent Events where the browser supports them โ heartbeats, per-write deadlines, a bounded stream โ and fall back to plain polling on their own
rastrillo/carlos (v0.19.0) โ scheduled work on a platform that keeps your app asleep: carlos.Tick is the constant-time bearer check that separates a real platform tick from an internet request to the same public path, TickOccurrence is the dedupe key that stays the same across at-least-once redelivery, and ScheduleAt/ScheduleCancel register one-shot timers that live in the box's registry rather than in your process, so a restart loses none of them; recurring schedules are declared from outside the app with carlos schedule set, and the app's whole part is a guarded handler
Pending
Mergeable transport โ edge sync is the platform's designed territory; eventlog.Ingest is the seam waiting for it, and until it lands, generated mergeable ids stay writer-local
Schema evolution for declared resources โ generated stores emit only the initial CREATE; changing a manifest's fields is still the app's own ALTER, not a generated diff
Event provenance โ every generated mergeable mutation is stamped actor "app" today; threading the session's actor into store events is the recorded follow-up