Planet4.Me docs
Open the app

#Firestore

Paths, all household-scoped except users/ and usage/:

users/{uid}
usage/{uid}
households/{hid}
households/{hid}/members/{uid}
households/{hid}/stores/{storeId}
households/{hid}/tasks/{taskId}
households/{hid}/staples/{stapleId}
households/{hid}/purchases/{purchaseId}
households/{hid}/items/{itemId}
households/{hid}/sessions/{sessionId}
households/{hid}/suggestions/{suggestionId}
households/{hid}/notes/{noteId}
households/{hid}/chatMessages/{messageId}
households/{hid}/notes/{noteId}/photos/{photoId}
households/{hid}/notes/{noteId}/presence/{uid}
households/{hid}/groups/{groupId}
households/{hid}/people/{personId}
households/{hid}/questions/{questionId}
households/{hid}/serviceEvents/{eventId}

households/{hid} carries the household's own facts — name, region, memberUids, the profile and the dismissals — and lookBack: {days, overrides, surfaces: {trips, calendar, people, questions, todos}}, how far back the app reads (see src/lib/lookBack/). Written at birth stating the default so the document says what the app does; read totally, because the household update rule validates no field; changed only through setLookBack, which clamps to whole days in [1, 365] with dotted paths, so one surface's change leaves the others alone and a household from before the map grows it on the first write.

serviceEvents is the failure record — the intelligence layer's first, and now the writes' too — {fn, code, message, at, uid}, at from the device clock like everything else. A refused write records as fn: 'write' with a message that opens with WHAT was being written in a person's words ("a to-do — INVALID_ARGUMENT: …"), because the code alone cannot say that and it is the one thing somebody opening the list wants to know. Every seam in src/data/intelligence.ts returns null rather than throwing, which is the layer's whole contract and does not change; what changed is that the failure is now written down on the way past instead of vanishing, so a household that finds an assisted feature has gone quiet can find out why. code is the FirebaseError code the browser actually received — the cause itself stays in Cloud Logging.

The seam reaches the writer through one module-scoped sink (setIntelligenceFailureSink), set by the app shell, which is the only thing that knows both the household and the signed-in user; threading a writer through every hook and screen would put the diagnostics into every signature in the app. Writes are fire-and-forget by construction — this is the error path, and a failure to record a failure must not become one — and each write opportunistically prunes a handful of rows older than thirty days, because there is no background pass in this app and housekeeping is not worth inventing one. Settings lists the recent ones under Service, absent entirely when nothing has failed: a record, never a badge or a count.

groups holds the household's CUSTOM to-do/notes groups — {name, tasks, notes, createdAt, createdBy} — one namespace serving both tabs. (Both tasks and notes say which DOOR shows the group; there is no third flag for the calendar, and deliberately — see the Calendar section and DECISIONS, "The calendar filters the namespace; it does not join it".) The id is opaque: an auto-id, immutable, and unrelated to the name, which is a display attribute the household renames whenever it likes (renameGroup writes that one field and nothing else, which is why renaming needs no migration — every list keeps pointing at an id that never moves). Ids written before that was true are folded names and still resolve; that is history showing through, not a rule, and nothing derives an id from a name in either direction. See DECISIONS, "A group's id is opaque; its name is display". The built-ins (Personal, Work) have no documents — layer 1 supplies them virtually, which is what makes them undeletable, and unrenameable by the same sentence (a shared household's Work is not deleted either: it is simply not a shelf there, and shows only while something is filed under it — builtinShelves). tasks/notes flags say which front doors show the group; joinGroup flips one and never touches the name; the recursive member rule covers the collection like every other.

parent — a group id, or absent for the top of a door — is how groups nest. ONE field on the ONE document both doors read, which is what makes a move land on Tasks and on Notes at the same instant: there is no per-surface parent to leave behind. A built-in may be a parent (they are doors, not documents), which is only possible because canonicalGroupId ended the two-word shelf — a child stored under 'home' and one stored under 'personal' would otherwise have rendered in different folders on the two doors.

people holds everyone the household names in its own prose — {name, aliases, nickname, job, uid, createdAt, createdBy} under an opaque auto-id, for groups' reason and with groups' consequence: the id never moves, so nothing anywhere has to be rewritten when the name does. A person is born at a note's commit — the blur, or leaving the note with something typed, since the browser's back button takes the editor away without one — whole, from the first @handle that resolves to nobody, never from the light save while typing; no note stores a person id, because the mention in the text is the link, resolved by detectPeople on every read. aliases holds the folds the person also answers to: renamePerson pushes the old name's fold on (a surname learned in March is a rename of one document, never an edit to any note), and mergePeople folds any number of people's handles into the survivor's and deletes their documents — one write on the survivor, one delete each — and deletePerson removes one nothing names any more, one delete and nothing to touch. nickname is what the household calls them for short, typed on purpose on the person's page (setNickname; null until then, and blank reads as null): one more fold the person answers to (personFolds — name, nickname, aliases — is what detectPeople, the @ menu, the People search and the search grammar all resolve through), shown and editable where the aliases are hidden history, and what the @ menu splices beside the name in parentheses (handleFor), which detectPeople reads back as one mention with the parenthetical as one more fold — so a mention typed by hand as @bob_(Bobby) births Bob with the nickname Bobby. A merged-away person's nickname joins the survivor's aliases. job is what they do — "plumber", "the letting agent" — the nickname's shape again (setJob; null until then, blank reads as null, one line), except that no mention resolves through it: it is a fact about the person, shown on their page and carried to a merge's survivor when the survivor has none — and one the People search and the merge card's roster answer to (searchPeople, on the job's own search fold, so "plumb" finds every plumber the household wrote down and an accent costs nothing). Its box completes from the jobs the household has written for everyone else (jobUniverse / jobChoices in src/lib/people/jobs.ts, the # menu's bargain on one field: prefix on the search fold, newest person first, no "new job" row). Its box is a field of its own under the tools line (PersonField, the nickname's box beside it), the chips under it while it is focused and the typing is not yet a job, picked on pointer-down so the pick beats the blur. uid is the account this person IS — null for everyone until a member says "This is me" on the person's page (claimPerson, which also writes null onto whichever entry that account claimed before, since an account is one person; releasePerson is "Not me"), and carried to a merge's survivor when the survivor has none, the job's rule. It is the one link from a person to a sign-in, and what it unlocks is a reading: personForUid (src/lib/people/me.ts) says who the signed-in account is, and mentionsPerson / questionFor say whether a text or a question is theirs — through the mention scanner, never a field, so a to-do is Sam's because it says @sam, whoever typed it. See DECISIONS, "An account is a person who said so". Timestamps are device-clock Millis like everything else. The recursive member rule covers the collection as written — no rules change — and it holds tens of documents, sorted client-side, so no index either.

questions holds what the household asked in its own prose and KEEPS after answering — {text, groupId, askOf, sourceNoteId, askedAt, askedBy, answer, answeredAt, answeredBy, archivedAt}. The one derived thing that earns a document, because the answer changes somewhere other than where the question was written. groupId is the group the source note lived in, so a question inherits its shelf and every filter that shelf already has (moveQuestion writes that one field; readQuestion runs it through canonicalGroupId and defaults 'personal', the notes' reader's own rule). askOf is a person id or null — a question addressed to nobody is a first-class state, one about the boiler rather than for a person — and a null or unresolvable askOf simply renders without a chip. Tags are not stored: detectTags reads them off text on every render, so editing the question edits its tags. sourceNoteId naming a deleted note yields no way back rather than an error. Answering is one gesture landing two records (answerQuestionStampingNote, completeTaskStampingNotes' shape): the answer on the document, and the source note's pinned line stamped (answered Sep 3, 2026: two years from install) so the note stays self-contained and the answer stays findable by the note search. A question nobody is going to answer is put AWAY instead — archivedAt, the notes' own state and the notes' own manners: archiveQuestion writes the one field and no edit stamp, restoreQuestion clears it, deleteQuestion is offered from the archived shelf alone, and the source note's pinned line is left exactly as it is, because the pin is the idempotency and rewriting it would re-arm capture (DECISIONS, "A question is put away, not answered falsely"). Every reader treats an archived question as it treats an archived note — off the index's lists and counts, the group shelf, the More count, the person and tag pages, the co-mention shelf, the note's chips and the assistant's snapshot — and readQuestion reads a document written before the field as live. Recursive member rule, no rules change; hundreds of documents at the outside, sorted and split client-side, so no index.

usage/{uid} is the intelligence layer's budget for one person — {day, units, month?, monthUnits?}: the UTC calendar day and the units spent on it, and the UTC month and the units spent in it (the month pair arrived later; a document without it reads as a month with nothing spent) — written only by the Cloud Functions through the Admin SDK (functions/src/budget.ts) and never readable or writable from a client. What a person sees of it comes through the usageReport callable, which reads their own document and charges nothing. See "The proxy" below.

assistantThreads/{uid}/messages is the assistant drawer's thread and the one place a transcript persists: {role: 'user' | 'assistant', text, at, by, photos?} with at from the device clock, by empty for the assistant, and photos — present only on a reply that shows some — an array of {noteId, photoId, url} references to note photos, never copies of them. Per member, not per household — see DECISIONS, "The thread in the drawer is yours": the conversation is readable by its owner alone, while every action a turn lands goes to the shared collections in the household's sight. The {uid} document itself never exists; only the subcollection under it. The client keeps roughly the newest twenty (trimChat); the listener orders by at alone, so no composite index. The rules carve this subtree out of the recursive member rule (exclusion in the catch-all, since OR'd rules cannot be narrowed) and re-grant it to the owner alone. The pre-migration chatMessages collection is left in place and still member-gated: current clients never read it, but a phone on the previous build does, and a permission-denied listener would read there as "can't sync".

#Security rules (firestore.rules)

Membership is the only axis. memberUids is denormalized onto the household document so the check is one cached get() per request.

  • users/{uid} — owner only.
  • usage/{uid} — nobody, explicitly. An unmatched path is denied anyway; the statement is there so the omission cannot be mistaken for one, and so the rules suite can assert it.
  • households/{hid} — read for members; update for members who remain members after the write, so a member can enroll someone or remove another member but can never write memberUids: [] — a state that would brick the household, since updating it again needs a membership nobody holds and delete is refused. Create is carved out for a user enrolling themselves, because the membership check cannot run against a document that does not exist yet; delete is refused. No field is validated — a member may write lookBack: 'banana' — which is why every reader of the household document is total. Update is also open to a non-member joining by invite (isJoinByInvite): the document carries inviteCode and inviteExpiresAt, set by any member, and a signed-in stranger who knows the code writes it BACK unchanged beside arrayUnion of their own uid. Writing the same value is the proof of knowledge — a wrong code changes the field and fails the equality — and an unchanged value is not an affected key, so diff().affectedKeys() may name memberUids alone. The array after must be the array before plus exactly one uid, the caller's own: no enrolling a friend, no touching the expiry to revive a dead link. The rules see the array AFTER the transform, which rules.test.ts proves — it is what the whole branch rests on. The code is never read by the joiner (they cannot read the document until they are in it) and is never spent by joining; a member re-minting or clearing it is how a leaked link is retired. Nothing on the server takes part, so joining works without App Check or a deployed function. Membership is not an ordinary field: a member may update anything but memberUids and ownerUid, and the array moves only by the invite branch (in), a joiner leaving (isLeaving: a shared household, not the owner, exactly themselves, nothing else touched) or the owner removing one other member (isRemoving: the owner stays). owner() is ownerUid, written at creation and never rewritable, falling back to the first member for a document from before the field. The owner can never leave and never remove themselves, so the household always keeps its creator and the empty array stays unreachable.
  • households/{hid}/{col}/{rest=**} — read and write for members, except assistantThreads. The exclusion lives in this rule's own condition because rules are OR'd across matching statements: a stricter nested rule cannot take back what a broader one grants, so the broad one has to decline first. The shape matters too: it requires at least one collection segment so it cannot match the household document itself. A bare {sub=**} also matches zero segments, which silently granted write on the household and overrode allow delete: if false.
  • households/{hid}/assistantThreads/{uid}/{rest=**} — that member only, and only while they are a member: the one subtree where membership is not enough. {rest=**} rather than a bare messages match, so anything the thread grows later inherits the owner-only shape.

tests/emulator/rules.test.ts covers the matrix, the invite branch included (live code, wrong code, expired code, no code, a friend, two at once, a sneaked expiry); tests/emulator/data.test.ts proves the bootstrap lands all three documents and rejoins instead of duplicating, that a shared household appends after the solo one, that a stranger joining by invite lands whole, and that addedAt / checkedAt survive a round trip as plain numbers rather than server sentinels. tests/emulator/storage.test.ts covers the bucket's rules (see "Notes" above), which read memberUids through the cross-service lookup and so need both emulators up.

#Indexes

firestore.indexes.json declares exactly one composite index: the purchase rebuild filters on stapleId and orders by at, the one query in the app that does both. Every other query filters on a single field, or orders by one, never both — the collections involved hold tens of documents, so sorting in the client costs nothing and avoids a composite index per view. The recent-trip listener orders without filtering, which a single-field index already serves. The emulator does not enforce composite indexes, so the emulator suite cannot notice a missing one; the index file is the contract that deploys it.