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.