#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 writememberUids: []— 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 writelookBack: '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 carriesinviteCodeandinviteExpiresAt, set by any member, and a signed-in stranger who knows the code writes it BACK unchanged besidearrayUnionof 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, sodiff().affectedKeys()may namememberUidsalone. 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, whichrules.test.tsproves — 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 butmemberUidsandownerUid, 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()isownerUid, 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, exceptassistantThreads. 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 grantedwriteon the household and overrodeallow 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.