# calls

A [rastrillo](https://github.com/rastrilloorg/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 under `gen/`
  (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
  with `rastrillo 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 from `store.title`. `pending_joins` is
  the fingerprinted table: an outsider's address is stored there only as
  a keyed fingerprint, while `persons.email` holds 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.go` is
  the entry authority (spec §2.3), computed per request, never stored.
- Sign-in: `auth.go` is 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 applies
  `form-action` to it); every other answer from `POST /signin` is
  `303 /signin?sent=1`, and the link is mailed after the response by
  `signin_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's `Refused` hook (never a query anyone could type). An emailed
  link opens the confirm door (`RenderConfirm`): `GET /auth/verify`
  spends 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. `mayRequestLink` does 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` + `.Select` allowlist.
- Migrations apply at boot, once each, and are never re-run or reversed.
- Schema changes: edit models.go, then run `rastrillo migration generate`
  and read the SQL it writes — it may be a full table rebuild, not just
  an `ADD 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=0` throughout: the
  stack is cgo-free by design.
- Native Apple bindings under `native/` use cgo to bridge Go to Objective-C.
  Their toolchain is isolated in `native/go.mod`; server releases remain
  `CGO_ENABLED=0`. Build with `make native-mac`.
- `make race` is the test exception to that rule, and the third gate:
  `-race` refuses to build without cgo, so it sets `CGO_ENABLED=1`. It
  builds a test binary and never a release, so nothing that ships gains
  a C dependency. Run it for any change to `internal/calls/hub` or the
  stream — a hub behind a mutex, a tick goroutine and one goroutine per
  stream is more concurrency than `make ci` can see.
- `make browser` is 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 with `frames` stuck 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, LISTEN_FDS, $STATE_DIRECTORY, `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.
