oficina / meet Public
calls
A rastrillo app. Read the framework's own SKILL.md (repo root, or in the module cache) before writing app code — it is the whole app story in ~15KB. The conventions below are load-bearing; the framework enforces most of them mechanically.
Layout
internal/<app>/— the app:models.go(plain GORM structs + Models),migrations.go+migrations/(Schema, BootSchema),app.go(migrate.Apply + chi router),handlers.go,render.go+templates/. This is SKILL.md's five-file shape.manifest/— the declarative path, optional and equal: TOML or .go Resource manifests, each generating a full screen set undergen/(see manifest/README.md for the mounting recipe). A hand-written action file at a generated path (carrying//go:build rastrillo_actions) takes that one screen over, by existence.gen/— generated when manifests are used; never edit. Regenerate withrastrillo generate.internal/calls/seal/— the app's only encryption surface. A title is sealed by the store and opened by the store; no handler holds a plaintext title it did not get fromstore.title.pending_joinsis the fingerprinted table: an outsider's address is stored there only as a keyed fingerprint, whilepersons.emailholds a verified address in plaintext, the suite's current shape for a person (spec §2.1, as in Docs, Slides and Calendar). Meeting invitation tokens are looked up by hash and sealed for repeat sharing by members. Legacy hash-only invitations remain valid; first sharing creates a sealed invitation alongside them. Replacing a link revokes all current ones.internal/calls/store.go— every database access.standing.gois the entry authority (spec §2.3), computed per request, never stored.- Sign-in:
auth.gois Docs' shape with the pending-join exception of spec §3.2 and Slides' same-answer wrapper. Keymail or an emailed link is chosen by the address's domain alone, never by membership (Paul, 2026-10-06). A Keymail address, member or not, gets the continuation page to Keymail (never a 303 out of the form POST: Safari appliesform-actionto it); every other answer fromPOST /signinis303 /signin?sent=1, and the link is mailed after the response bysignin_mail.go's worker, only if this request may have one, so neither the answer nor its timing says who is a member. A refusal after verification is the door, rendered in the refusal's own 403 by auth'sRefusedhook (never a query anyone could type). An emailed link opens the confirm door (RenderConfirm):GET /auth/verifyspends nothing, so a mail scanner cannot burn it, and the door's one Sign in button POSTs the token back to/auth/verify, mounted for both methods in both modes.mayRequestLinkdoes the same database work for a member and an outsider and logs nothing on the response path.
Rules the family holds hard
- Every query touching user-owned rows goes through
scope.Owned— reads AND writes, transactions included. A row that isn't yours 404s, never 403s. - Never bind a request body onto a GORM model: explicit
map[string]any+.Selectallowlist. - Migrations apply at boot, once each, and are never re-run or reversed.
- Schema changes: edit models.go, then run
rastrillo migration generateand read the SQL it writes — it may be a full table rebuild, not just anADD COLUMN. Never edit a migration that has shipped. Renames are hand-written (rastrillo migration new rename_x) because a rename is indistinguishable from a drop plus an add. - Money is integer cents. A float never touches a value a person will be held to.
- Screens work with JavaScript disabled, with two exceptions: browser media participation (the holding screen's camera preview and join button, and the call itself in the room), and, once a call exists, the room's chat panel and its admission queue's Admit controls; both pages still render their title, standing, floor notice and navigation without script. Destructive actions get their own confirm-page URL.
- The gate is
make ci(vet + gofmt + tests) — the same definition CI runs. Run it before every push.CGO_ENABLED=0throughout: the stack is cgo-free by design. - Native Apple bindings under
native/use cgo to bridge Go to Objective-C. Their toolchain is isolated innative/go.mod; server releases remainCGO_ENABLED=0. Build withmake native-mac. make raceis the test exception to that rule, and the third gate:-racerefuses to build without cgo, so it setsCGO_ENABLED=1. It builds a test binary and never a release, so nothing that ships gains a C dependency. Run it for any change tointernal/calls/hubor the stream — a hub behind a mutex, a tick goroutine and one goroutine per stream is more concurrency thanmake cican see.make browseris the second gate, for any change to a page or to the client: a real call in real Chromium. It needs the Claude Code bash sandbox off — the sandbox blocks UDP, so Chromium gathers no ICE candidates and every call fails withframesstuck at 0, which reads exactly like a client bug and is not one.
Serving
rastrillo.Resolve + Serve speak the CARLOS activation contract (socket/addr/db argv, LISTENFDS, $STATEDIRECTORY, sidecar run) — the golden deployment target, not a requirement: ./app -addr :8080 serves anywhere. GET /healthz and GET /api/version are answered by the framework, outside app middleware.
Deploying
make release VERSION=<sha> builds and stamps the artifact; carlos deploy --app calls --account oficina --channel edge --version <sha> --target linux-arm64 releases/calls-linux-arm64 ships and promotes it, waiting until /api/version serves it — but only once carlos apps build-probe --app calls --account oficina /api/version has been run for the app (set on the flagship app since 2026-09-09); a new app or account needs that first, or the wait watches X-Carlos-Version instead, which can report the version adopted before the process has actually changed. A migration applied under an earlier filename fails at boot; otherwise migrations never re-run. Every deploy, and every relay config change, restarts the instance and drops live calls — no drain exists. The wait can time out before hand-over — check /api/version before retrying. See the README's Deploying section for the canary recipe.
UX conventions (seeded from profile: considered)
Accessible by construction, progressively enhanced, and never dependent on JavaScript to be usable, with the exceptions named above: browser media participation, on the holding screen and in the room, and, once a call exists, the room's chat panel and admission controls.
These are rastrillo's own conventions, and the profile is named for what it does rather than after anyone else's work. For wider reading on interface quality, impeccable.style, the WAI-ARIA Authoring Practices and Inclusive Components are all worth your time — none of them is endorsed by, or authoritative for, this framework.
- [x] Icons — lucide, inline —
internal/<app>/icons - [x] Fields — label, hint, help and error in one envelope, wired with aria-describedby —
ui/field, ui/field-select, ui/field-text - [x] Destructive actions — their own confirm-page URL, never a modal —
ui/confirm-form - [x] State — never colour alone — always a text label too —
ui/status-pill, ui/badge - [ ] Dates — relative, with the absolute date in a title attribute
- [ ] Errors — server-rendered beside the field, in plain language
- [x] Empty states — say what the screen is for, not just that it is empty —
ui/empty-state
[x] is enforced by a vendored component: use it, do not hand-roll an equivalent. [ ] is guidance to apply by hand. A convention absent from this list is one rastrillo has no opinion about.
Edit any line. This file is the source of truth, not the profile name. The profile only seeded this list at scaffold time; nothing re-reads it, so changing a line here changes what the app does. A convention deleted from this file is not a convention to reinstate.
Work in a worktree, and catch main up first
Take a worktree for the work. Do not work in the shared checkout.
git -C <repo> fetch origin git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main
Both halves matter, for different reasons.
The worktree, because several sessions share this machine and some share this repository, and a checkout has one HEAD, one index and one working tree between all of them. A branch switch under somebody else's uncommitted work destroys it with nothing on screen to say so, and two sessions editing the same paths disagree quietly instead of conflicting loudly. A worktree gives the work its own HEAD and its own files, and costs a directory.
From origin/main, after a fetch — not from whatever the checkout happens to be sitting on. A branch cut from a stale main is a merge conflict scheduled for later, against files somebody else has renamed in the meantime. A branch cut from another task's branch is worse: it carries that task's commits into yours, where they are reviewed as though you wrote them, and the two can only be separated by hand.
Remove it when the work has landed — git worktree remove — so that git worktree list keeps saying something true.
Written down because of 2026-09-17, in this repository, while it was still called meet. The shared checkout was sitting on one task's branch while holding a different task's feature uncommitted on disk — some four hundred lines, a new migration among them — put there by a session nobody else knew was working in that tree. Two other sessions were meanwhile coordinating over the same checkout by asking each other not to touch it, which is the arrangement a worktree makes unnecessary. Nothing was lost. Nothing about the arrangement would have said so if it had been, and by then the same feature existed twice, once on each side of a rename, because the two sessions had branched from different starting points.