INFO
This page is DECISIONS.md from the repository, as it stands.
Decisions
One entry per non-obvious call, with the reasoning rather than just the outcome. Code is recoverable by reading it; reasoning is not. Without this file someone — the author in eight months included — will let a client write memberIds, move the vote guard back to "the author may edit anything", or make the assistant helpfully announce every plan change, and re-introduce a problem that was designed out on purpose.
Entries are grouped by area. Most of the history predates this file and was reconstructed from the code, its comments and the commit log; new entries go at the bottom of their area, or in a new area at the end. When a decision is reversed, the old entry stays and is marked superseded.
Product
The app suggests; humans decide
The guiding rule from the original design brief, and the one every assistant feature is checked against. The model drafts the plan, proposes, and populates polls; it never locks anything, and lockHangout is the organizer's transaction alone. Nothing on the server flips status to confirmed on the assistant's behalf, and the prompt says so ("Never claim anything is locked"). A planner that decides for the group is a different, worse product: the point of the tools living in the chat is that the crew's own decisions are visible and theirs.
Anonymous identity by default, durable accounts as an upgrade
Opening the app signs the visitor in anonymously and asks only for a display name. A link is all anyone needs to participate, which is the whole invitation model — a signup wall in front of "we should get dinner" kills the plan. Google or an email link can be attached later, and either links to the same uid so memberships survive; a returning user on a new device gets Continue with Google to pick their account back up. The features that genuinely need durability (the paid memory upgrade, the email digest) say so and refuse anonymous accounts, rather than forcing everyone through sign-up for the few who want them.
Decision widgets are singletons
Date, time, food-venue, activity-venue (and the legacy combined venue poll) exist at most once per chat. A duplicate splits the crew's answers across two ballots and no tally means anything; the tool sheet disables a dropped singleton, and the assistant appends to the existing card rather than making another. Open-ended widgets — generic polls, activity-type votes, yes/no questions — can be dropped freely because each one asks its own question.
Readiness lives on the member, not on a card
"Ready up" is a plan-level approval signal on each member doc (ready), counted on the status banner and the itinerary. The earlier "Are you in?" poll made readiness one widget among many and let it get buried; as a member field it survives widget churn, needs no singleton bookkeeping, and is what lockHangout's announcement can read.
Hangouts expire by inactivity, not by date
The cleanup sweep deletes a confirmed hangout 30 days and an unconfirmed one 90 days after its last activity (updatedAt, bumped by every message, vote, plan edit and assistant run). The plan stores its date as free text, so there is no reliable event date to key off — and keying off activity has a better property anyway: a hangout the crew is still touching, including one for a genuinely upcoming event, is never swept. Two windows because a locked plan naturally goes quiet after the event, while an unconfirmed one is still "left sitting" and deserves the benefit of the doubt for longer.
The privacy policy renders above the auth gate
/#/privacy is resolved in App.tsx above the gate chain rather than as a route, because it is linked from the signed-out gates, which render instead of the router. The page needs no data, so it can be reachable in every auth state — and a policy that a visitor can only read after signing in is not much of a policy.
The signed-out gate is a public landing page
Google's OAuth verification wants a home page viewable without login, and a curious visitor deserves one too. The name gate therefore opens on a landing pane that names the app and explains it, and Get started reveals the name / sign-in pane — pages on phones, side by side on wide viewports, arranged in pure CSS so the input never remounts mid-typing.
Client
Hash routing
Every screen is a /#/… URL. Invite links (/#/invite?code=…) are then plain static URLs that work on any host without server-side routing, survive the service worker's navigation fallback, and can be pasted into another chat and unfurled. The cost — an ugly # — is paid once; the alternative, history routing, needs a rewrite rule per host and still breaks the moment a link is opened somewhere the rewrite isn't configured.
Inline styles from CSS strings, over design tokens
The Roundup design was authored as HTML prototypes with inline CSS strings. css() converts those strings to React style objects, so the prototypes were ported verbatim with pixel fidelity instead of hand-translated to camelCase, and the values live on the tokens in src/index.css so the palette has one home. Going forward the preference (see CLAUDE.md) is media queries over wide ternaries for presentational differences and shared components over duplicated JSX — but the string-style idiom is the one the codebase speaks, and a component library would be a second idiom on top of it.
No state library
Firestore's listeners already hold the shared state and push every change; React state holds the rest. A store would be a second source of truth for data that is already synchronized. The one place that needed more — a tapped vote painting before its transaction lands — got a purpose-built optimistic overlay in the chat controller, not a store.
onSnapshot lives in data.ts, never in a component
Every subscription is a hook in the data layer, and every write is a function there. Screens stay renderable from plain props, the data layer is the one place that knows a Firestore document's shape, and a rules change is a change to one file's callers rather than a hunt across the tree.
Three viewport tiers: 820, 1100, 1200
820 px is exactly an iPad Air's portrait width, so iPads get the multi-pane chat in both orientations; below it the shell is the original 480 px phone column, byte-identical to the design. 1100 px is where thread + expanded widgets column + map fit (about 1072 px of minimums) — iPad landscape and desktops; below it the widgets fold into the icon rail. 1200 px is where the chat toolbar can also hold its status pills inline; tablets in landscape (1180, 1194 px) are full-tier for the panes but fold the pills into the info popover to keep the toolbar one line.
The chat is one brain with two bodies
useChatController owns every layout-agnostic piece of state and behavior; ChatPhone and ChatWide are renderers. When the viewport crosses 820 px live — an iPad rotation — the picker swaps the body but the hook stays mounted, so drafts, open sheets, optimistic overlays and scroll pinning survive the flip. A single component branching on wide throughout had become the duplication hotspot; two components sharing one hook is the same behavior with no forked state.
useWide is for structure; media queries are for presentation
A boolean in JSX for a difference that is purely size, spacing, wrapping or visibility forks the tree for no reason and drifts. Those live in index.css under @media (min-width: 820px), kept in step with WIDE_QUERY. The wide boolean is reserved for a genuinely different component tree (a pane instead of a sheet).
The service worker is hand-written, and there is one of them
Push needs Firebase Messaging's push / notificationclick handlers, and the PWA needs Workbox precaching; FCM's default separate firebase-messaging-sw.js would fight the PWA worker for the scope. So vite-plugin-pwa runs in injectManifest mode with src/sw.ts doing both. The navigation fallback denylists /__/* — Firebase Hosting's same-origin auth helper, which the OAuth redirect flow navigates through — and /email/*, the server-rendered vote endpoint: every digest recipient is an app member whose browser carries this worker, and without the entry the fallback served the app shell for every emailed vote link.
The keyboard inset is measured, not assumed
Chromium shrinks the layout viewport for the on-screen keyboard (interactive-widget=resizes-content), so 100dvh tracks it. iOS never does; the keyboard overlays the page and a bottom-anchored composer disappears behind it. App.tsx mirrors the visual-viewport overlap into --kb-inset and the shell subtracts it — skipping pinch-zoom, which also changes the visual viewport but isn't a keyboard.
Sponsored cards and the notifications nudge are client-side only
Both render as inline cards in the thread, and neither is ever written to Firestore. A persisted card would land in the assistant's transcript (describeCard labels an unknown kind as a poll with a fake tally) and fire onMessageCreated, burning a summary run per placement. Display-ad networks were evaluated and rejected for the feed: AdMob is native-only, and AdSense forbids placements that mimic content — an ad styled as a chat bubble risks a policy strike for near-zero revenue at this volume. What remains is honest: a labeled house card every 25 messages and plain search deep-links on the itinerary, with AFFILIATE_TAG empty until a real program exists.
The admin panel is a lazy chunk with one importer
Regular users never download the operator panel: AdminRoutes is lazy-loaded behind RequireAdmin, and adminData.ts may be imported only from src/screens/admin/*. One import from a main-bundle module would silently pull the whole panel into every user's download; the rule is written at both ends.
Optimistic card overlays over transactions
Every card write is a read-modify-write transaction so two members voting at once can't clobber each other — but transactions have no latency compensation, so without help every tap waited a full round-trip before the widget moved. The controller applies the same pure step the transaction will run and paints it as an overlay for that message, handing back to the live card once the echo satisfies the tap's own settled predicate. The predicate is per tap because the default — "my own response matches" — is right for votes and answers (the rules pin every other member to their own entries, so nobody else can perturb it) but wrong for option add/remove, which don't change my response and would settle instantly and snap the option back. A safety timeout covers a concurrent structural edit that leaves a predicate permanently false. Reactions are field-path updates and get latency compensation for free, so they need no overlay.
Card writes to one message are chained
A burst of taps — marking several calendar dates — ran concurrent transactions on the same document, which contend and retry each other, so each tap cost several round-trips. Writes to the same message now queue one at a time; each still re-reads the card inside its own transaction, so it sees the previous tap's result.
Orphaned votes are excluded at read time
When an option is removed, the rules don't let the remover touch other members' vote entries, so a vote can point at an option that no longer exists. Rather than a privileged rewrite of everyone's ballots, every tally, completion check and "has responded" test — on the client (selectedKeys, hasResponded) and the server (pollResponders, memberResponded) — filters to the card's current option keys. The stored data is left honest and the derived numbers are right.
Identity is denormalized, because profiles are private
Co-members can't read each other's users/{uid} docs, and shouldn't: the profile holds home and work addresses, preferences and memory notes. So the name, avatar color and Google photo a crew sees come from the member doc in each hangout, refreshed on rename or photo change; friends get theirs from the edge doc; an invite carries its hangout's title and host. The cost is a fan-out write on rename; the benefit is that nothing private is readable by the people you plan with.
Avatar colors are deterministic and persisted as hex
A member should look the same everywhere, so the color is a hash of the uid over a six-color palette drawn from the Mars tokens — and it is stored on the member doc as a literal hex value rather than a token name, because it is rendered by every client, stamped onto messages, and mirrored by the server and the seed; a token indirection would have to be resolved in three runtimes. The palette is kept in step in all three places.
Firestore and the rules
Membership is granted only by Cloud Functions
memberIds and the member docs are written by joinHangout and respondInvite through the Admin SDK; a client can create a hangout only as its organizer and only as its single initial member. If a client could write memberIds, a creator could seed other people's uids into a hangout and make an attacker-controlled chat appear on their home screens; if a client could add itself, an invite code would be decorative. The same is why kickMember, leaveHangout and deleteHangout are callables: the fields they touch are immutable from the browser.
Clients post only their own user messages, under their own name
ai and system messages come only from functions. A client message must carry the caller's uid, and its senderName and avatarColor are pinned to the caller's own member doc — because the transcript the model reads attributes lines by sender name, a member who could post as "Maya" or as "Nova" could steer the plan. The member display-name rule refuses the assistant's name for the same reason, in any casing or padding, and the server mirrors the rule (sanitizeDisplayName) where it writes names Admin-side.
A new card arrives response-free, and every vote diff is pinned to its caller — author and organizer included
Two forgeries are closed. A card could be posted pre-seeded with other members' uids in votes / multiSel / answers, faking a tally or a "poll complete" that steers the assistant — so the create rule requires empty responses. And the author could post a clean card, then update it pre-seeded — the two-write version — so the update rule pins the votes, multiSel and per-question answers diffs to the caller's own uid with no exception for the author or the organizer. The two edits that legitimately rewrite everyone's entries (single ↔ multi, which migrates every vote; editing a question list, which re-keys every answer) moved server-side into editCardStructure, behind the callable's own author-or-organizer check. What the author and organizer keep is the title; what every member keeps is adding and removing options — an MVP trust trade-off within a crew, made safe by orphan filtering.
Everything that reaches the prompt is bounded
Message text (4000 chars), card titles, hangout titles, and the profile fields that flow into the prompt (name, home, work, the memory list) all carry size limits in the rules, and runSummary clamps each transcript line again. An unbounded own-document write would otherwise be a free prompt-stuffing and cost lever on every summary run. Square brackets are stripped from typed text on the way into the prompt because the system prompt marks widget tallies as bracketed lines — a member typing [Date survey — Saturday: 9] would be indistinguishable from a real tally.
Server timestamps are pinned by the rules
A message's createdAt must equal request.time: the transcript is ordered by it, and a client-chosen time could reorder what the model reads. A hangout's updatedAt is pinned on every client update because it is the activity clock the cleanup sweep keys off — a client that could back-date or future-date it could get a hangout swept early or keep it forever.
Whitelists, not blacklists, on every client write
Hangout creates and updates, member updates, profile creates and updates and admin config writes all enumerate the keys a client may touch. The server-owned bookkeeping — pendingMessages, lastSummarizedAt, the ai block, bannedIds, venueArea's neighbors — must never be settable from a browser: a pendingMessages of 1 on a fresh create would queue a paid model run the moment the doc landed. Enumerating the allowed keys means a new server field is safe by default.
Admin is a custom claim, and it is read-only
Operator access is admin: true on the token, set only by scripts/grantAdmin.mjs through the Admin SDK — there is no document a client could write to obtain it, and the rules compare it with a strict == true so a truthy lookalike is refused. The claim grants read across users, hangouts, messages, members, entitlements, runs and stats, so the panel can review conversations, and writes to exactly two bounded surfaces: review flags and the digest configuration. Even an admin cannot post a message, edit a plan, or read another user's push tokens. A compromised admin token should be an oversight problem, not a takeover.
Some collections have no rules at all
rateLimits, placePhotos and internal have no match block; Firestore denies every client request by default, and only the Admin SDK reads them. That is the safest possible posture for data no client has any business seeing — the emailed-vote HMAC key above all — and it costs nothing. The rules tests prove the key is unreachable from every client, admin included.
Rules can't loop, so question checks are unrolled
Security rules have no iteration, so "no question arrives with answers" and "each question's answers changed only at my key" are written out per index, up to the composer's cap of eight questions (MAX_QUESTIONS, mirrored in QuestionComposer and logic.ts). A ninth question is refused by the rules rather than silently unchecked.
Photo URLs are pinned to Google's CDN, in three places
An account photo is rendered as <img src> by every client that can see the person — co-members, friends, the admin panel. An unconstrained URL is a free way to plant a tracking beacon, or arbitrary imagery, in other people's chats under someone's name. The rules (validPhotoUrl), the client (safePhotoUrl in src/types.ts) and the server (safePhotoUrl in logic.ts, applied wherever the Admin SDK copies a photo — those writes bypass the rules) all accept only https on googleusercontent.com / ggpht.com, and '' means "use the initial bubble". Only a linked Google account may clear its own picture: an email-only account has nothing authoritative to compare against, and reading "no Google photo" as "clear the photo" wiped avatars out of every hangout.
One reaction per member per message, from a fixed palette
The tapback model: re-reacting replaces yours, re-picking the same emoji clears it. The rules let a member change only their own key in reactions, and only to one of six emoji mirrored from REACTION_EMOJI, so the map can't be stuffed with arbitrary text. One key per member also makes the write a field-path update, which gets latency compensation and needs no optimistic overlay.
Data and identity
Google or email links to the anonymous account; it doesn't replace it
Both upgrades link the credential to the current uid, so every membership comes along. Signing into a new account instead would strand the guest account's hangouts on this device. When the credential already belongs to another Planet4.Us account the app switches to it — but refuses if the current guest account has hangouts to lose, and says why, rather than silently swapping.
authDomain is the page's own origin in production
Firebase Hosting serves the /__/auth/* helper on every connected domain, so the OAuth handshake can stay first-party on whichever domain the user is on. A fixed authDomain runs it on a third-party origin whose storage browsers now partition; the popup path mostly survives, but the redirect fallback bounces back signed out — and iOS standalone, the case the fallback exists for, is exactly where that bites. VITE_FIREBASE_AUTH_DOMAIN is only the fallback on localhost, which has no helper. The price is one manual step per domain: its /__/auth/handler must be added to the OAuth client's redirect URIs (README → Deploy).
Popups where they work, a redirect in the installed app
An installed iOS PWA opens popups in a disconnected in-app browser with no window.opener and partitioned storage, stranding the auth handler on its "missing initial state" page. signInWithGoogle uses a popup in a normal tab and the full-page redirect flow when the app is standalone; the redirect completes at boot, before the anonymous fallback, so a returning redirect signs into the Google account instead of being shadowed by a fresh anonymous session.
Boot waits for persistence before falling back to anonymous
currentUser is null for a moment at cold boot. Signing in anonymously right away replaced a restored Google or email session with a fresh anonymous user — signInAnonymously only reuses restored anonymous sessions — so the auth hook waits for persistence to restore first. The email-link completion waits for the same reason: linking must target the existing anonymous account, not a new one.
Deferred sign-in failures are published, not swallowed
A Google redirect reloads the page, so the promise a screen was awaiting is gone; an email link opened from the inbox finishes at boot after the screens have mounted. Both used to fail into a swallowed catch, leaving the user signed into nothing with no explanation. Now useAuth completes them and publishes failures through useAuthCompletionError, and the pending email-link state is cleared so a reload doesn't retry a dead code (links get consumed by mail scanners).
Emulators are a dev-build concept
Env files leak into every mode, so VITE_USE_EMULATORS alone can't be trusted to keep a production build off localhost. Emulator connection is gated on import.meta.env.DEV as well; in a dev build the emulators are on by default and VITE_USE_EMULATORS=false points npm run dev at the real project. The dev sign-in panel rides the same gate and tree-shakes out of production.
One project id for the emulator, the seed and the client
The Firestore "Multiple projectIds" mismatch — emulator on one id, seed on another, client on a third — is a class of bug worth deleting. scripts/localProject.mjs reads VITE_FIREBASE_PROJECT_ID from the same env files Vite would and both the emulator launcher and the seed use it; with no env file everything agrees on demo-wavelength, whose demo- prefix keeps the emulators offline.
Profile writes are debounced and flushed on the right uid
Text inputs call update() per keystroke, which was a Firestore write and a snapshot echo per character. Local state stays instant; the persisted write coalesces. The flush is guarded on the uid still being current, because a flush fired by sign-out or an account switch would write to the old uid, be denied by the rules, and lose the edit — the sign-out handler flushes first.
Recent connections are read once, not subscribed
"People you've hung out with" is a discovery list; N live member subscriptions for it would be pure cost. It is one-shot reads over the hangout list's most-recently-active order, so the dedupe keeps each person's freshest name.
The assistant
Quiet by default: it speaks only when spoken to
Nova's chat message is "" unless the crew addresses it by name, sends a message in message-Nova mode, or answers a question it asked. Three address states drive the prompt: direct (the newest message speaks to it), window (it was named within the last five spoken messages, but the newest is probably crew-to-crew chatter — silence is the default, a reply is permitted only for an unmistakable follow-up), none. One address earns at most one reply; a completed poll updates the plan silently. The alternative — an assistant that greets, acknowledges, nudges and announces — was tried in spirit by the prompt's earlier drafts and adds noise the crew reads as spam; "doing something is the response" is the rule the prompt now spells out. The wake word is uncommon on purpose so it never triggers by accident.
Ambient runs are throttled; the paths that matter bypass the throttle
At most one ambient run per hangout per 12 minutes (SUMMARY_INTERVAL_MIN), with a 5-minute sweep to catch the tail — deliberately infrequent, because the paths where latency matters go around it: an addressed exchange gets an 8-second debounce, a poll reaching full participation is fast-tracked, and the organizer's manual button runs immediately (rate-limited at 4 per minute, since "post a trivial message, press update" would otherwise be a paid-model lever any member could pump). Every path claims the slot in a transaction on lastSummarizedAt, so concurrent triggers produce one call, not N.
Widget posts don't count toward the listen window
A dropped card is a type: 'user' message with no typed text. Left in the window, a few widget drops would push a recent "Nova, …" out of the five-message wake window and downgrade an addressed exchange to the slow ambient throttle. Address state is decided from spoken messages only; the transcript the model reads still keeps the cards as context.
Structured output, at medium effort
The model returns one JSON document against a schema (output_config.format), so the plan edit, the widget request, the memory notes and the venue searches are fields, not prose to parse. effort: 'medium' because 'low' made the model narrate actions ("putting up a poll") without reliably filling the widget field; medium keeps it faithful to the tool fields, and Sonnet still keeps the cost at single-digit dollars a month at MVP volume. max_tokens is 4096 — generous, because too low a cap truncates the JSON and makes the whole response unparseable.
A blank plan field means "leave it"; removals are explicit
The model's plan edit fills only fields it is confident about, and blank means unchanged — never "clear this". Without that rule a run that simply had nothing new to say would wipe a date the crew had settled. Genuine removals ("forget that venue") go in a separate clear list, and the price field is filled only when the crew actually talks about cost, never inferred from a venue's price level.
dateText is the date; plan.date is legacy
Every write path and the UI use the free-text dateText; plan.date is never written. The prompt reads dateText so the model sees a date the crew already settled and doesn't re-infer (and overwrite) it from old chat. A structured datetime is the recurring dependency in BACKLOG.md — the itinerary builder — and until it exists, the cleanup sweep keys off inactivity precisely because no real date is available.
Deterministic model failures clear the queue; transient ones don't
A 4xx from the API (revoked key, context overflow, unknown model), a response with no text block, or unparseable JSON fails identically on every retry. summarize() throws those with a summarize: prefix, and runSummary clears pendingMessages before rethrowing so the sweep doesn't re-issue — and re-bill — the same doomed call every five minutes forever. Rate limits, overload, other 5xx and transport errors stay unprefixed, leave the counter set, and get retried. Returning null is reserved for "no key configured": mistaking a truncated response for that would silently and permanently drop a plan update.
The intro notice is static, and written once under a fixed id
The note that opens every chat — the assistant listens quietly and speaks when named — is a hard-coded system message costing nothing, not a model greeting. It is written with create() under a fixed id because Firestore event delivery is at-least-once, and add() turned every redelivery into a second copy. The fixed id also lets the seed pre-write the same doc, backdated, at the head of its fake history; the trigger's late write then fails ALREADY_EXISTS and is dropped.
Singletons are found by index, not in the transcript window
When the model asks for a date, time or venue widget and one already exists, its options are appended to the existing card. That lookup is a targeted query across the whole chat rather than a scan of the 60-message window: an original poll that has scrolled past the window would otherwise be missed and a duplicate singleton created, splitting the crew's votes. Appends re-read the card inside a transaction so votes cast during a slow model call aren't clobbered by a write built from a pre-call snapshot.
Proactive venue suggestions are the one unprompted move, and they are silent and bounded
When a member floats a concrete idea — "ramen?", "how about bowling" — that no poll option reflects, the model may request a venue search and the server drops 1–3 real places onto the matching food or activity poll, creating it if there is none. It never announces this; the options appearing is the response. Each entry is a paid Places call, so the model's enthusiasm is bounded in code, not trusted: at most 2 searches per run, counts clamped to 1–3, only onto a poll with 2 or fewer options, never an idea already on the card, and each term remembered in aiSearched (capped at 30) so it isn't re-searched every run. A Google failure is logged and the term is not remembered, so a later run can retry.
Memory notes cross a trust boundary that is re-checked on both ends
The prompt reveals a [memory id:…] tag only for members who hold the entitlement, so the model cannot even name a non-entitled member in memoryUpdates. The server validates anyway (planMemoryUpdates): a note lands only on a uid that is both entitled and a member of this hangout, duplicates are dropped, and each list is capped at 20 with the oldest dropped first. The prompt forbids sensitive attributes and anything said about someone else. Buying the upgrade requires a durable account, checked against the Admin Auth record rather than a possibly stale client token, because a paid benefit on an anonymous uid would vanish with a cleared cache; revoking is allowed for anyone. The checkout itself is a stub that only works in the emulator or under ALLOW_STUB_UPGRADE — the seam a real payment webhook replaces.
Run telemetry is a counter document, not a sum
Every run writes an aiRuns record for the inspector, and bumps monotonic counters in adminStats/llm and a daily rollup. The counters exist because a collection-group sum over aiRuns would shrink as the cleanup sweep deletes hangouts (and their runs) — usage history would quietly erode. Recording is best-effort and never throws: oversight must not break, or retry, a summary. Failed runs are recorded too, with their real token spend.
A departed member's votes are stripped
When a member is kicked or leaves, their votes, selections and answers are removed from every card in the hangout, Admin-side. Stale responses would otherwise skew the tallies the model reads and could hold a poll at "fully answered" before an actual current member had voted. Kicking also bans the uid (bannedIds, checked by both join paths); there is no un-ban path yet, so removal is final for now.
Venues, maps and location
Places is proxied server-side, with its own key
The venue search calls Google from a Cloud Function, so the key sends no HTTP referrer — and a referrer-restricted key (the Firebase web key) is rejected with API_KEY_HTTP_REFERRER_BLOCKED. So there are two keys: a server-only Places key with no application restriction, stored as a secret and never seen by a browser, and the ordinary referrer-locked browser key, which runs the client-side Maps JavaScript SDK. Without the Places key the callable reports disabled and every venue input falls back to free-text "add a place"; the map is a "switch to List" notice without a real browser key. Both are supported states.
Anyone who loads the app is signed in, so paid endpoints need more than req.auth
Anonymous tokens are free to mint, so a signed-in caller is not a real participant and a script could drive Google billing or enumerate invite codes. The Places callables require the caller to be a member of at least one hangout (always true for the in-chat widget) and are rate-limited per uid; invite-code preview and join are rate-limited so the code space can't be brute-forced for titles and hosts; every other endpoint that fans out real work (pushes, invites, friend requests, the manual summary) has a per-uid window of its own. The counters live in rateLimits/{uid}, unreadable by clients.
Details are fetched on demand, and photos are keyless
Place Details bills more than Text Search, so hours, price, phone, website and photos are fetched only when someone opens a place's details, never on every search. Photos are resolved with skipHttpRedirect=true, which returns a keyless googleusercontent URL the browser loads directly without ever seeing the key; the one photo a digest email shows per venue is cached in placePhotos/{placeId} for 30 days — including "this place has no photo" — so each venue costs at most one billed lookup a month however many digests mention it.
The search area comes from members' homes only with an explicit opt-in — always
venueArea is persisted on the hangout, visible to every member, and fed to the assistant, so a home that a member hasn't chosen to share must not shape it, coarsened or not. The one derivation, deriveGroupAreaFor, counts only members with shareLocation on, and groupPlanContext, the suggest callables, the summary pipeline and the widget-drop seed all go through it so they can't drift. A saved home is not permission to use it; the toggle on the profile is the permission, and it says exactly what is shared ("Sharing your area as 'Williamsburg'"). The same opt-in gates a member's near: line in the prompt and their origin bubble on the map.
Coarsening happens before anything leaves the server
coarseArea drops any comma-segment carrying a digit — a leading street number, a number-last European street, a unit, a zip — and keeps at most the trailing two digit-free parts, so "12 Bedford Ave, Williamsburg, Brooklyn" becomes "Williamsburg, Brooklyn" and a bare street line becomes nothing rather than leaking. Over-dropping the odd legitimately numbered locality is the accepted cost; the contract is that a street address never enters a prompt or a member-visible field. Origin bubbles round coordinates to about a kilometre and label by neighborhood or city, and the geocoding itself runs server-side so the exact point is never resolved in a browser.
The crew's planning context is aggregates only
The food widget's cuisine chips, the suggestion bias and the travel constraint all come from members' private profiles, so groupPlanContext runs server-side and returns a union of cuisines, one coarse area and the tightest travel limit — never any individual's home, preferences or full profile. The tightest limit, not the average, because the least-mobile member is the bottleneck.
The model keeps a set search area unless the chat names a new one
Members can correct the area from the widget, crew-wide; if the model re-inferred it on every run it would overwrite the correction. So the prompt shows the current value and asks for "" ("keep it") unless the conversation names a different area after it was set; the model infers only when nothing is set, and a member's or the model's stored value always beats the location-derived seed.
Search results are published up through refs and survive the sheet closing
On the collapsed layouts the map is only reachable after the widget sheet closes, which unmounts the search component. If results were cleared on unmount, search pins would never be usable on the map. The results are published up through a ref, live in the chat controller, and self-clean: added places drop out, a deleted widget's entry is skipped, and a remounted search's first publish resets them.
Notifications, email and friends
Push is a per-device choice, not a profile setting
A browser that opts in registers its own FCM token under the account; another device makes its own choice. That matches how permission actually works (per browser, from a user gesture — iOS rejects prompts that aren't) and avoids the lie of a profile toggle that is "on" on a phone that never granted permission. Tokens rotate silently, so each launch re-asserts the token; sign-out detaches it so the next account in the browser isn't buzzed about hangouts it isn't in. Only two FCM error codes prune a token — not-registered and invalid-registration-token — because invalid-argument can be a payload bug, and pruning on it would deregister healthy devices until sends went quiet.
Test pings are shown by the page
FCM displays nothing while the app is focused — right for chat, but the Profile's Send a test notification would look broken every time, which is exactly how people test. Messages carrying the server's test: '1' flag are displayed by the page itself; everything else stays suppressed in the foreground. The button reports how many devices were reached, and every failure in the enable flow says which of its three steps failed (permission, FCM registration, the Firestore write).
The digest is strictly opt-in and mails only the verified address
A daily email needs two things: the digest flag the user's own toggle writes, and an email on the Auth record. The client-writable profile email mirror is never used, so a profile edit can't aim mail at an arbitrary inbox. An admin can opt a user in from the panel — that runs Admin-side because profile writes are owner-only — but it flips the same flag the user's toggle shows and can turn off again, so the user keeps the last word, and it is logged with the acting admin's uid.
Operator controls change behavior without a deploy, and can't crash the sweep
The digest's kill switch, cadence, window and gap, cap and templates live in adminConfig/digest, read on every run. The rules bound the doc, and resolveDigestConfig clamps everything again — wrong types fall to defaults, numbers are clamped, strings bounded — so a bad write can change behavior but never produce an unbounded email or a crash. The kill switch stops manual sends too (off means off); the weekday cadence gates only the schedule, because an admin pressing "send now" on an off-day is explicit intent, not the calendar.
Votes from the inbox are signed links, and a GET never votes
Email clients run no JavaScript, so a widget can't be live in an email. Each option is a link carrying a token — uid, hangout, message, choice, 14-day expiry — HMAC'd with a key minted once into internal/emailActionKey. The token is the authentication: the recipient's browser has no app session (anonymous auth is per device), so identity has to travel in the email. A GET renders a confirm page and mutates nothing, because mail scanners prefetch links; only the confirm button's POST applies the vote, and on a multiple-choice widget it adds rather than toggles so a repeated request can never retract a vote. Minting the key into Firestore rather than a secret is zero-config, and rotating the doc invalidates every outstanding link.
Email is one function over one HTTPS call
Resend is called with a single POST; there is no SDK, and swapping providers means reimplementing sendEmail() alone. It never throws — a digest iterates many recipients, and one bad address must not abort the rest — and returns the provider's actual error sentence so the panel can say why a send failed (Resend's test sender only delivers to the account owner, which is the usual first surprise). Under the emulator the rendered email is logged instead of sent: seeded accounts carry made-up addresses, and a local run with a real key would fire real mail at them.
Friend edges are mirrored and written only by functions
An edge is a doc under both users, written in one transaction so the mirrors can never disagree, and the rules deny every client write so nothing can be forged or one side deleted. Sending a request to someone who already asked you accepts it — which is also how simultaneous mutual requests resolve, since the transaction serializes them. Declines are quiet everywhere: nobody is told. A pending request in either direction is not a friendship for the purpose of inviting, and an invite already sent survives a later unfriending — it was sent while friends.
In-app invites live in the app, because iOS opens installed apps only from a push tap
Invites are an inbox doc plus a push, not an OS share sheet or a link: on iOS only a notification tap can open the installed Home-Screen app. The recipient's copy carries the hangout's title and host because they can't read the hangout before joining; the member-visible marker lets the picker show who has already been asked. Accepting re-checks bannedIds at that moment (a kick may postdate the invite) and funnels through the same join transaction as the invite code, so the "joined" notice exists exactly once.
Operations
Backend failures land in a collection, best-effort
Email rejections, dead vote links, sweep crashes and push fan-out failures are recorded to serviceErrors so the admin Overview can show what has been breaking without a trip to the Cloud Functions logs. Recording never throws — an error log that could break, or retry, the operation it is logging would be worse than none — and everything still goes to the structured logs for the full trail. Entries age out after 30 days in the cleanup sweep.
Diagnostics name the reason, per recipient
A digest run is a funnel — changed hangouts → opted-in members → deliverable recipients — and a zero can strike at any stage. The panel's run result names the empty stage, lists every skipped opted-in user with the exact reason (no auth email, inside the send gap), and carries each failed send's provider message. A flat "0 sent" once contradicted the global opt-in count on the same screen and was undiagnosable; naming the stage is the whole point.
The admin gate refreshes the token once before denying
A freshly granted claim rides the next ID token, so without a retry an admin would have to sign out and back in for /#/admin to open. RequireAdmin force-refreshes once, then denies. The gate is convenience only — slipping past it shows an empty panel, because the same claim gates every read in the rules.
Tooling and documentation
No CI, but every check runs locally and end-to-end is scripted
The repository has no workflow files. The checks a contributor runs — the two typechecks, lint, the pure unit tests, the rules tests under emulators:exec, the docs build — are all one command each, and the end-to-end pass (emulators, seed, Vite, a Playwright drive) is written up in .claude/skills/verify/SKILL.md so it is repeatable by a person or a model. CI would be the natural next step; until then this is the bar before a push.
Lint runs with two deliberate exemptions
react-hooks/set-state-in-effect is off: the app loads async data in effects with setState-driven loading and error status — a deliberate, pre-React-19 idiom used with alive-flag and race guards throughout — and the React-compiler-era advisory flags every such effect. @typescript-eslint/no-explicit-any is an error, with fenced eslint-disable islands where Firestore snapshot plumbing and the Maps SDK genuinely need it. The codebase predates the config; those islands stay honest now that lint runs.
The seed is idempotent and rich
npm run seed wipes and rewrites its hangouts, the friend graph and the invites every time, so a test session can be reset without clearing the emulators. It seeds four hangouts in different stages, filled-in profiles (so the preference-driven features have data), two members who share their area and one who doesn't (so the opted-out path is always exercised), and an admin account with the claim already set. The alternative — an empty emulator and a demo you rebuild by hand — makes every manual check expensive enough to skip.
The docs site is a second Hosting site with the app's own skin
The user guide lives in docs/ as VitePress, deploys with the app through a predeploy hook, and wears the app's tokens so it reads as part of the product. Screenshots are real captures from the seeded emulator app at fixed phone and desktop widths, retaken the same way when a screen changes. docs/ABOUT.md — an older internal product overview — is excluded from the site because the guide (for people) and ARCHITECTURE.md (for developers) supersede it.
The Developers pages include the repository's documents as they stand
docs/developers/*.md are thin wrappers: an info callout, and <!--@include:--> of README.md, ARCHITECTURE.md, DECISIONS.md, BACKLOG.md or CLAUDE.md from the repository root. A copy would be edited in one place and not the other within a month; an include can't drift, and the docs build fails on a dead link in any of them. The documents reference each other by filename in backticks rather than by relative link, because a relative link that works on GitHub cannot also resolve on the site (and vice versa); the two links the README needs into the repository tree are absolute GitHub URLs for the same reason. lastUpdated is off on these pages since the timestamp would be the wrapper's, not the document's.