Skip to content

Why it is like this

The reasoning behind this project lives in its commit messages, which is a good place for it and an undiscoverable one. This is the index: the decisions that would look arbitrary or wrong to somebody arriving now, and what they cost.

Each has the commit to read if you want the full argument.

Product

The phone edits, the server does not. From the PRD. It makes the server small enough to cost a few dollars a month, and it makes the app a real piece of engineering rather than an upload form. The cost is the whole native module, and it is most of the difficulty in this repository.

Silences are not trimmed. The VAD is implemented, tested, and switched off. Cutting somebody's pauses reads as the app editing over them instead of for them — an invasive edit. One constant decides it: TRIM_SILENCES in src/edit/trimming.ts. 3932356

Publishing sits above editing. The review screen exists because publishing something you have not seen is frightening, not because anybody wants a timeline. So the default path is one tap and the editing controls are below it. 7d5bb77

Gallery import contradicts the PRD, on purpose. The spec ruled out the camera roll to force footage shot in the moment. It was added later at the product owner's request, and items from the library are tagged in the manifest so the decision stays visible and reversible.

A re-roll is not a strike. The group choosing somebody else is a decision, not a failure. Missing a deadline is the failure, and that is what strikes count.

Strikes never remove anybody. They are counted, they are shown, and that is all. Being ejected from a group of friends is not a thing the software should decide on its own, and a circle that throws people out loses the joke; the group already has the re-roll vote, and the owner can remove somebody deliberately. What strikes are for instead is the second roulette in roadmap.md — they turn its prompt into a forfeit, so the cost of missing a deadline lands inside the game rather than on your membership.

The draw sends two notifications, not one. Everybody used to get the same third-person line, so the vlogger's own phone announced their turn to them as though it belonged to somebody else and told them to come back in a few days. It is the one notification in this product that asks for something, and it now reads that way to the person it asks.

The vlogger is told how their vlog did. Days of filming, a minute of assembling, and the turn used to end in silence on the same tick that drew somebody else. Closing a premiere now reports back with the two numbers that mean anything: who watched, and who reacted.

Reactions are five emoji, fixed. An open field is a field nobody scrolls, and counts that cannot be compared between vlogs. Five things the whole circle can read at a glance is the trade; adding a sixth is one line in REACTIONS. They stay available after the premiere closes, because the 24 h window is about who can still watch, not about when somebody is allowed to say they liked it.

Who watched, by name. The names were in vlog_views from the start and the API returned only len(). In a closed circle the names are the interesting half: "Marta y Luis lo han visto" is a fact about your friends and "2" is not.

A flip is one take, not two. From explicit feedback: everything from pressing record to pressing stop is a single take however many times the camera turns around. f250c24

A circle has a door, and it can be changed. The invite code was permanent and by design pasted into group chats, nobody could be removed, and an owner who left took the only account able to administer the circle with them — so a "closed" group had no way to become closed again. Rotating the code stops new arrivals and removing a member deals with whoever is already inside; both are owner-only, and both were needed, because either alone leaves the hole open.

An owner who leaves hands the circle to whoever has been in it longest. Longest-standing rather than most active, because it is the rule that needs no explaining at the moment it happens. The last member out leaves the circle pointing at them, which is inert rather than broken: an empty circle draws nothing.

Muting a circle is a member's own setting, not the owner's. It sits next to the invite code and the cadence in the circle's menu but is nothing like them: PATCH /circles/{id}/mute is open to any member, changes only the caller's own row, and is invisible to everyone else in the circle (#95). It does not touch membership either — a muted member can still be drawn, still records, still votes to re-roll. The only thing it silences is their own phone, which is why it needed none of the safeguards an owner-only setting would.

Saving a vlog is a plain download, not a backup feature. "Guardar en el dispositivo" (#94) asks for the narrowest permission that does the job — write-only access to the gallery, never read — downloads the finished mp4 into the app's own cache, and hands it to the OS to save. It is a copy of what is on screen right now, not a sync: saving twice saves twice, and nothing here tracks whether it was ever done.

Architecture

The video never passes through the API. Presigned upload, CDN playback. A premiere is the one moment every member of a circle arrives at once, which is precisely when the API cannot afford to be moving 50 MB per viewer.

The EDL is duplicated in TypeScript and Python. Two implementations of the same thing, with mirrored tests, so the phone and the server produce the same vlog. Sharing it would mean a runtime bridge between the two, which is a larger and more fragile dependency than the duplication.

Exactly one server machine. The turn scheduler and the rate limiter both count in memory, so a second machine runs every tick twice and doubles every limit, both silently. Making this scale out is real work — the schedule into the database with a lock, the limiter into something shared — not a config change.

Pushes go out after the commit, never before. Every push is queued while the change it describes is being made and sent once that change has committed -- by the tick per turn, and by an endpoint after its response, through FastAPI's BackgroundTasks. Sending first meant a commit that then failed left the circle told about a vlog or a draw that had been rolled back, and the retry told them again; sending inline added up to ten seconds of Expo to publishing a vlog, with the transaction held open. The cost is that a push can now be lost after a change that did land, which is the cheaper failure: a missed notification, not a false one. 867556a (#141)

One open turn per circle is the database's rule, not the code's. A partial unique index over pending, recording and rendering rather than trusting draw_new_turn to check before it inserts: two re-roll votes crossing the line together, or a forced draw racing the tick, used to leave a circle with two live turns, one of them striking a vlogger who never saw it. The loser of the race now treats the failed insert as "somebody already drew". The migration had to settle existing duplicates first, as skipped so nobody was punished for the server's mistake. 867556a (#142)

The upload ticket names the size and the type, and the bucket is asked afterwards. The signed URL is bound to video/mp4 and to the exact byte count the phone declared, so R2 refuses anything else; and complete reads the object's real size and type with a head request instead of believing the phone, deleting it if it is too big or not an mp4. Either check alone would do on a good day. Both are kept because the bucket is served publicly by the CDN, and the first one used to sign whatever content type it was handed. 04489bf (#143)

Rate limits count per rule, not per path. Routes with an id in the path are matched by named patterns and share one counter per client: staging a take under a new filename, or for another turn, draws on the same budget. Counting per full path meant every new filename opened a fresh bucket, so the upload limit never bit -- while the prefix rule also caught the heartbeat, votes and complete, which nobody had decided to limit. The cost is a list of patterns that has to grow with every new route worth limiting; anything not on it is unlimited. 04489bf (#158)

Render results merge, they do not overwrite. A finished render used to save the whole manifest, including the clip list it started with, undoing anything edited while it ran. saveRenderResult writes only render outputs. 045efb9

The server translates its own errors, the app does not. Every error detail is written once, in English, and translated on the way out from the request's Accept-Language. Mapping status codes to sentences in the app was the alternative, and it cannot work: a 400 means a dozen different things here, and only the code that raised it knows which. The cost is a table in app/core/i18n.py that has to grow with every new HTTPException -- test_every_error_detail_has_a_spanish_version fails the build when it does not.

Spanish when nothing is said, English for any other language. Two different fallbacks, on purpose. A request with no Accept-Language comes from a build released before the app spoke English, which is Spanish-only, so the server answers it in Spanish -- and existing accounts start out es for the same reason. A phone set to a third language gets English, the language it is likelier to read.

Renders are superseded, not cancelled. MediaCodec has no cancel. The queue runs one at a time and discards results whose generation number has moved on. ca7bb82

Accounts and tokens

Revocation is a version number on the user, not a denylist. The token carries the account's session epoch; bumping the number on the row invalidates every token ever issued to it. No table to store, nothing to expire, and it cannot drift out of sync because it is read from the same row the request already loads. The cost is that it is all-or-nothing — you cannot sign out one device — which is the right trade for a closed group app where the reason to press it is a phone you no longer have.

Access tokens last an hour; refresh tokens sixty days. A month-long access token was fine while the only user could be asked to reinstall. It is not once other people have accounts: an access token cannot be checked against anything until it expires, so its lifetime is the exposure.

The refresh token is not rotated on use. Rotation with reuse detection is the stronger design and it needs the tokens stored server-side, which is a table, a cleanup job, and a new way to lock somebody out of their account by losing a race on a flaky network. With revocation already working account-wide, the cost outweighs what it buys here. Written down so it is a choice.

A password reset is a code you type, not a link you tap. A link needs somewhere to land — a website, or deep links registered against a domain — and this project has neither. The code is safe because of three things together, no one of which is enough: it expires in fifteen minutes, it works once, and guessing it is rate limited to ten attempts an hour.

Email goes over SMTP rather than a provider's API. Every transactional email service wants a verified domain before it will send to anybody but you, and there is no domain. SMTP works with whatever account exists today and with a proper provider later, unchanged.

Changing or resetting a password ends every session. Otherwise the change accomplishes nothing against the person you changed it because of.

Email verification is informational, not a gate. Registering with somebody else's address by mistake used to only surface later, if a password reset they never asked for landed in their inbox. A code now goes out at registration, the same shape as a password reset (app/services/reset.py is shared between the two), but an unverified account can otherwise use the app exactly like a verified one -- create circles, join them, record, publish. Blocking any of that on a flag nobody is forced to clear is a bigger product call than "tell the real owner somebody used their address," and it is not this decision. The screen for it is skippable for the same reason: docs/ data-model.md's email_verified column exists to be shown, not checked. Sending the code at registration is also the one place where a failed send does not fail the request -- the account already exists by then, and a 502 there would leave it stranded with no way back but a 409 on retry.

Backend email format validation stops at what Pydantic's EmailStr already does. The mobile client only checks for an @ (login.tsx); a verification code is what actually proves an address is real and reachable, which makes a stricter backend regex mostly redundant with a feature that already ships in the same change. Left as a deliberate non-decision rather than a gap.

Deleting an account scrubs the person, not the circle's history. The row survives because turns and vlogs point at it, and because the vlogs were made for a group that is still watching them — they stay in the archive, under "Cuenta eliminada" / "Deleted account" in each reader's language (chosen per response, never stored: #157). What goes is the address, the name, the password and every registered push token, and the account leaves every circle on the way out with the same handovers as leaving them one at a time. Deleting outright would take other people's history with it; keeping the account reachable would not be deletion.

A push token belongs to a device, not an account. users held a single token until #118, so a phone and a tablet signed into the same account fought over it — whichever registered most recently silently took the other's place, and reinstalling did the same to whatever was there before. push_tokens is its own table for exactly this reason: an account can hold as many live rows as it has devices, and a push goes to every one of them.

Logging out one device clears its push token, and only its own. Revocation here is token_version, a single number on the account — see above — so a per-device logout has no session of its own to end on the server. It used to leave the token alone for that reason, on the grounds that a token can only receive a push, not authenticate anything. That undersold what a push says: "it's Marta's turn", "X commented on your vlog" kept arriving on a phone nobody was signed into (#148). So the app now names its own token in DELETE /auth/me/push-token before forgetting its credentials — best effort, since a failure costs a stray notification, not access. logout-all and deleting the account still clear every token at once; after logout-all the asking device registers its own again straight away.

A dead push token is deleted only if nobody has re-registered it since. Expo answers DeviceNotRegistered for an uninstalled app forever, and until the tickets were read push_tokens only grew. Deleting on that answer alone races a reinstall: the install can register the same token again while the push that reported it dead is still in flight. So the delete is conditioned on the row's updated_at being older than the moment the send began -- a token that has just proved itself alive survives one stale verdict, and a genuinely dead one goes on the next push. 867556a (#147)

Deleting an account asks for the password. Same reasoning as changing one: an access token lasts an hour and cannot be checked against anything until it expires, so a stolen one must not be enough to erase somebody.

Infrastructure

Fly.io, one machine, Amsterdam. Fly has no region in Spain, and of those near it only ams/fra/lhr offer managed Postgres. The database has to sit with the app; the video does not, and comes from Cloudflare's edge. c50bbb7

Neon instead of Fly's Managed Postgres. The smallest Fly plan is $38 a month against a free Neon project, and at this size the difference buys reliability the project does not need yet. It cost one change: DATABASE_URL.

The scheduler ticks every 15 minutes, not every minute. Neon's free tier suspends the compute when idle and that suspension is the saving. A query a minute keeps it awake permanently and exceeds the monthly allowance. Turns come round every 3 to 7 days, so a draw landing a quarter of an hour late is beneath anyone's notice. f75df5d

R2 rather than S3. Egress. The shape of this product is one upload read by every member of a circle, every week, forever, and R2 charges nothing for that.

Backups live on a laptop, not in the cloud. Everything else here is rented and a backup is the one thing that should not be: its value comes from being independent of what it copies. A copy that production's own credentials can reach is no protection against those credentials being misused. The gap that leaves — a laptop switched off for a fortnight — is named in deployment.md. 2d81ce8

A systemd timer, not cron. cron silently skips runs that fall while the machine is off, which on a laptop is most nights. That is how people end up believing in backups they do not have.

Engineering

Coverage is measured on pure modules only. A global percentage over a codebase that is mostly screens and native bridges measures how much React got rendered in a test, which is worth nothing. jest.config.js lists the modules where the number means something, and says so. 5c706c3

CI compiles the Kotlin. Three consecutive EAS builds failed on compile errors at nine minutes each before this existed. 3ae1040

The npm audit job reports everything and fails only on critical. Twenty-odd advisories, all transitive through Expo's build tooling, none fixable. A job that is permanently red is a job nobody reads. 8ce47fc

Error reporting scrubs this app's own vocabulary. Sentry strips passwords and auth headers by itself; it cannot know that a push token identifies a device or that an invite code is a working key to somebody's circle. Keys are removed and the values around them kept, because an event with everything stripped explains nothing. c5bbb25

A flaky test was replaced with measured bounds. test_everyone_comes_up_over_a_long_rotation asserted a spread of 2 across 60 rounds, which failed roughly one run in six. Measured over 4000 simulations the extremes were 7 and 15, so the bounds are now 5..19 — loose enough never to flake, tight enough to catch a draw that has genuinely stopped rotating. The reasoning is written into the test.

Rejected

Raspberry Pi behind a Cloudflare tunnel. Documented and then dropped for something more professional. It left one mark worth knowing: the tunnel meant the origin was unreachable directly, which is why the rate limiter used to trust a forwarded header. On a host with a public address that assumption is worth nothing. a6a827a

ffmpeg-kit-react-native. Deprecated, binaries withdrawn from Maven Central. This is why the native engine is written directly against MediaCodec.

A second bucket in the same Cloudflare account as a backup. It feels like a backup and only survives the mildest failure.