Planet4.Me docs
Open the app

#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 — including the author in eight months — will "simplify" median back to mean, or let a background pass write directly to staples, and re-introduce a bug that was designed out on purpose.

Newest entries at the bottom.


#Median and MAD, never mean and standard deviation

One vacation, one bulk run, one week with houseguests wrecks a mean, and the squared term in a standard deviation amplifies exactly the outliers a household's shopping data is full of. The median of the last six intervals drifts with your life instead of averaging two-year-old habits into today, and MAD degrades gracefully when a single interval is absurd.

#The window is the last six intervals, not all of them

Six is short enough to track a change in household rhythm within a couple of cycles and long enough that one strange trip cannot move the median. Longer windows make the app feel like it is arguing with a version of you from last year.

#Device clock for checkedAt, startedAt, and purchase at

serverTimestamp() resolves when the write reaches the server, not when it happened. A Saturday check-off that syncs Sunday morning gets stamped Sunday, and every interval is off by a day, silently, forever. Grocery stores have terrible signal, so this is the normal case rather than an edge case. A separate server timestamp may be kept for ordering, but never for interval math.

#purchases/ documents are the truth; staple.purchaseHistory[] is a cache

Firestore array appends do not merge: two offline clients both appending to the same array resolve last-write-wins, and one person's purchase vanishes silently, corrupting cadence. Each check-off therefore writes its own purchase document with its own id, which cannot collide. The array on the staple is a denormalized read cache so that rendering forty staples costs forty reads rather than three hundred. It is rebuildable from purchases/ whenever it looks short or stale.

#Capped arrays use read-modify-write, not arrayUnion

arrayUnion cannot trim the head, and the cache is capped at 20 entries. The cost is that two concurrent writers can drop an entry from the cache — which is acceptable precisely because the cache is not the truth and can be rebuilt.

#Whole days, floored at one

Sub-daily purchase intervals are rare, and their precision is noise against a twelve-day rhythm. Rounding to whole days makes every readout speakable ("about every nine days") and removes a class of floating-point drift from the median.

#Households from day one, invite flow skipped

The app ships for a single user, but every task, staple, item, and session lives under households/{hid}/. Generalizing later would mean rewriting every query and every security rule. The invite flow, by contrast, is one screen that can be added at any time without a migration — so it is not built.

#Security rules allow household creation without a membership check

isMember() calls get() on the household document, which does not exist during signup bootstrap. Creation is therefore allowed when the authenticated user appears in the incoming document's memberUids — you may create a household you belong to, and nobody else's. Without this carve-out the app cannot bootstrap a new user under production rules.

#Local time throughout

Seasons are "what month is it where you live" and a shopping trip belongs to the day you felt you were on. A UTC day boundary falls mid-afternoon for much of the world and would split trips and seasons in the wrong places.

#Firebase web config lives in environment variables

Not because it is secret — Firebase web config is designed to ship in the client bundle, and the apiKey is a project identifier rather than a credential — but so that pointing at a second project for development is a config swap rather than a code edit.

#No analytics

getAnalytics is a console default, not a choice. It costs bundle weight and introduces a cookie-consent surface, for a single-user app that has nothing to measure.

#No state management library

Firestore's real-time listeners already hold the shared state and push changes; React state holds the rest. Redux or Zustand here would be a second source of truth for data that is already synchronized, solving a problem the app does not have.

#react-router-dom for navigation, despite the no-libraries posture

The no-library rule targets state, not navigation. Correct back-button behavior in an installed standalone PWA is exactly the thing a hand-rolled History API router gets subtly wrong, and the dependency is boring and self-contained.

#Hand-written CSS rather than a framework

Six screens, a mobile-first layout, and a token file. A utility framework would be more bytes and more indirection than the entire stylesheet it replaced.

Superseded in part — see "antd-mobile for the mobile chrome" below. The reasoning still governs everything a product decision is embedded in.

#antd-mobile for the mobile chrome, themed from the tokens

Seven screens and native-feeling chrome — a bottom tab bar, screen headers, touch controls — is past the point where hand-rolling each control wins. Ant Design Mobile supplies those pieces at mobile density with real <button> and <input> elements underneath.

The token file remains the single styling authority: antd-mobile is themed entirely through one CSS-variable bridge (src/styles/adm-theme.css) that points every --adm-* variable at an existing token, so dark mode stays a pure media query and a palette change still happens in one file.

Hand-written CSS survives wherever a recorded decision is embedded in the markup: the two-target row with its oversized check-off area, the non-modal question surfaces (store picker, Coming up, the running-low shelf), and the self-expiring undo bar. Those are product behavior, not chrome, and no library component reproduces them faithfully.

#Season-crossing gaps are excluded before the window is taken, not after

The interval engine assumes time-since-last-purchase predicts the next purchase. For seasonal goods that is simply false: sunscreen is not due 365 days after the last bottle, it is due when it gets hot. Sunscreen bought three times a summer for two years gives raw gaps of roughly 21, 18, 240, 25, 19 — a median wrecked by the one gap that is really an off-season. Dropping those gaps first leaves a real in-season cadence from year one.

The ordering is load-bearing and has its own test. Windowing first and excluding afterwards would silently shrink the window below six and, worse, would leave the choice of which six intervals survive dependent on how many off-season gaps happened to fall inside them.

#Seasonal staples are exempt from dormancy while out of season

Two independently reasonable rules collide here. Dormancy fires when an item is long overdue; a seasonal item is always long overdue in the off-season. Without the exemption the app asks "still buying sunscreen?" every winter, forever. Dormancy for a seasonal item has to mean it missed an entire season, not an interval.

#Adds and check-offs are two separate signals with two separate jobs

An add is evidence of intent and drives promotion (three adds of the same canonical name offers to track it) and out-of-stock detection. A check-off is evidence the thing entered the house and is the only input interval math may legitimately use.

Promotion fires on adds rather than check-offs deliberately: adds happen a trip earlier, and they still fire for the item you keep writing down and keep forgetting to buy — which is exactly the item most worth tracking.

#The session gap is 75 minutes, biased deliberately long

The failure modes are asymmetric. Too short splits one trip in two: you check off produce, spend twenty-five minutes in the checkout line and loading the car, then remember milk — and the app learns a store association that does not exist. Too long merges two stores, which is more recoverable because the items usually came from obviously different lists.

For cadence the threshold barely matters — an hour of error is noise against a twelve-day interval. It is store learning that sets the value.

#Silence is the correct output for a bad prediction

Low-confidence staples never auto-add. This is the specific way apps like this die: irregular items get predicted badly, the list fills with wrong guesses, the user stops trusting anything the app added, and the feature is finished. An absent suggestion costs one forgotten item; a wrong one costs the feature.

#Regularity is measured on the per-unit series, not on raw gaps

A household that alternates a one-pack and a four-pack has raw gaps like 30, 120, 30, 120 — a regularity of 0.5, which bands as low and never auto-adds, despite a perfectly steady thirty-day per-unit rate. Measuring regularity on the series the prediction is actually built from is what stops quantity scaling being undone by the confidence gate that follows it.

medianDays and madDays stay on the raw observed gaps, because those are what a human readout means ("about every nine days") and what dormancy's grace period is counted in. So regularity is deliberately not madDays / medianDays, and the type says so.

#The prior's weight uses the windowed count; the prior is dropped on the total

The spec fixes both "the window is the last six intervals" and "drop the prior entirely at n ≥ 8". Those cannot both be about the same n — a windowed count never exceeds six, which would leave a quarter of a seeded guess in every prediction forever, however long the history.

Resolved by reading them as two different counters: the blend weight uses the windowed count, which is what makes the prior 25% at six observations, while the drop uses the total number of surviving intervals, on the reasoning that after eight observed gaps a seeded guess adds nothing.

#The prior blend applies from the first observed interval

Discarding a lone interval sounds conservative but has a sharp edge: without a prior — every staple when the intelligence layer is off, and any created before classification has run — gating the blend at two intervals means a staple with exactly two purchases has no due date at all — and because dormancy needs a due date, such a staple can never go dormant and sits in "learning" forever. That is the silting-up failure dormancy exists to prevent.

Confidence is unaffected: regularity is null below two intervals, so such a staple still reads as insufficient and still never auto-adds. The observation informs the readout and dormancy, never a prediction the user is asked to trust.

#Recurrence walks a local calendar grid, not a fixed number of milliseconds

Adding 86,400,000 milliseconds is not the same as adding a day. A daily task anchored at 23:30 walked across a spring-forward transition skips a calendar day entirely and lands at 00:30 from then on; a fall-back doubles a day. Stepping the local calendar keeps the wall-clock time of the anchor, which is what "the 1st of the month" or "every Monday morning" actually means to the person who set it.

The test suite pins TZ to a DST zone for the same reason: under UTC every bug of this class is invisible.

#The public barrel withholds the engine's internals

src/lib/index.ts deliberately does not export median, purchaseIntervals, blendPrior, confidenceFor and friends, even though each is exported from its own module for tests. Everything a consumer legitimately needs already arrives through CadenceResult.

Exposing the primitives invites a hook to write median(purchaseIntervals(h)), which silently skips prior blending, per-unit scaling and the seasonal gate — a second cadence path that disagrees with the first in ways nobody would notice. The named re-exports (rather than export *) exist so that adding an export inside a module cannot quietly widen the contract.

#Regularity cannot detect a bad merge

Two staples each genuinely bought every twelve days, offset by six and wrongly merged, produce a flat six-day interval: MAD 0, regularity 0. The merged staple looks like the most regular item in the dataset while being wrong by a factor of two, and the confidence gate will happily auto-add it twice as often as needed.

So a low regularity is never evidence that a merge was correct. The only thing that can catch this is a split proposal working from the raw sourceName values on the purchase records — which is why those strings are stored rather than discarded, and why the counterfeit case is pinned by a test that asserts the confidence band, not merely the statistics.

#The household lookup goes through the user document, not a query

Finding "my household" by querying households where memberUids contains my uid is the obvious implementation and it does not work. Firestore evaluates rules against a query, not against the documents it returns, so a query that might return a household the caller cannot read is rejected outright — even when it would in fact return only theirs.

users/{uid}.householdIds is the back-pointer that makes the lookup a single document read the user always owns. It is why the field exists.

#Household creation is two writes rather than one batch

Atomicity would be nicer, and it is not available. The subcollection rule calls isMember(), which reads the household document; inside a batch that read sees the state before the batch, where the household does not exist. The member document is denied on a null dereference and takes the household and the back-pointer down with it.

So the household is written first and the member document plus back-pointer follow together. The failure mode of the split is a household with no member document, which is cosmetic — memberUids is what the rules actually read — and self-heals on the next sign-in. The reverse order would strand a user pointing at a household that was never created.

#A bare recursive wildcard silently granted writes to the household

match /{sub=**} under /households/{hid} matches zero segments as well as many, so it matched the household document itself and granted write on it. Rules are OR'd across every matching statement, so this quietly overrode the allow delete: if false sitting directly above it — a member could delete the household, and the rule that said otherwise was decorative.

match /{col}/{rest=**} requires at least one collection segment and cannot match the parent. The emulator test that deletes a household is what caught it, and is what keeps it caught.

#A dev-only hook signs in against the Auth emulator

signInWithPopup loads Google's own script from apis.google.com even when Auth is pointed at the emulator, so the real sign-in path cannot complete in a sealed environment — which would leave the app with no automated end-to-end coverage at all.

signInAsTestUser hands the emulator a fabricated identity payload, which it accepts by design. It throws if it is ever called against a real project, and it is attached to window only in a dev build that is already talking to the emulator, so it cannot exist in anything deployed.

#Completing a recurring task re-arms it from the rule, not from the moment

The tempting implementation is "next = now + interval". It is wrong for exactly the reason the rule is fixed in the first place: a monthly task completed four days late would move to the 5th, then the 9th, then drift indefinitely. The next occurrence is the next slot on the rule's own anchor-aligned grid after now, so completing late costs nothing.

A recurring task is also never marked completed. It has no done state to reach — only a next slot. That is what keeps tasks symmetric with staples: a filter that is due works exactly like milk that is due.

#The quantity stepper appears only at check-off

At add time you do not know what you will buy, so asking then produces a guess that the cadence engine would then treat as fact. At check-off you are holding the thing. The stepper defaults to the last quantity, so the usual amount is one tap, and tapping the item through normally means one — the question can always be skipped, because blocking the aisle on a form is worse than assuming one.

#Promotion needs a second listener

Promotion counts adds across a household's history, but the list screen only subscribes to open items. Counting those alone could never reach three: an item added three times has usually been checked off twice by then. A separate subscription to checked items is what makes the count mean what it says.

#A check-off is one function because it is three effects

The item leaves the list, a trip opens if none is running, and a purchase enters the stream. Splitting these across the components that trigger them is how you end up with a check-off path that forgets to stamp the session, or a second one that records a purchase twice. useCheckOff is the only place it happens.

#Auto-add idempotency is a derived document id, not a lock

There is no server to serialize the sweep, and two phones opening the app in the same minute will both decide milk is due. Deriving the item id from the staple and the local day of the due date makes them write the same document instead of two — convergent rather than coordinated, which is the only thing that works when both devices may have been offline when they decided.

The cycle marker behind it exists for a second reason: without it, deleting an auto-added item would simply bring it back on the next sweep, which reads as the app arguing with you.

#The service worker precaches the shell and nothing else

Firestore already has an offline story — writes queue in IndexedDB and flush on reconnect — and it talks over a long-lived stream. A service worker with runtime caching over that would serve stale reads and interfere with the stream, and the symptom would look like data loss rather than a caching bug. So runtimeCaching is deliberately empty.

#Location is asked for at the moment it is needed

A permission prompt at first launch is refused far more often than one that arrives with something concrete to point at, and a refusal is effectively permanent. So the app asks nothing until the first check-off of a trip, which is the moment it would otherwise have to ask which shop you are in.

Everything downstream treats a missing fix as ordinary: there is often no GPS lock inside a large shop and no network for wifi triangulation, so the fallback is to ask rather than to guess or to report an error.

#Containment disqualifies an automatic merge, whatever the score says

"milk" is a substring of "2% milk", so edit distance and trigram both score that pair high. They score "almond milk" identically high. The first is more detail about one thing; the second is a different consumption stream, and merging it would average two unrelated rhythms into one meaningless number. No string metric can tell those apart, so none of them is allowed to decide — a candidate differing by a leading modifier goes to the model however well it scored.

The test that pins this asserts the metric's behaviour, not the code's: it shows both pairs scoring above the floor and both flagged as contained. That is the evidence the rule exists for, and it stops someone "simplifying" the check away after reading only the happy path.

#The shortlist includes head-noun siblings below the similarity floor

"whole milk" scores 0.42 against "2% milk", under the 0.45 shortlist floor, so a purely similarity-ranked shortlist would never show it to the model. That is precisely the candidate the model needs: a household already tracking both whole and skim is telling you that milk variants matter to it, which makes "2% milk" a third staple rather than more detail about the first.

So a candidate sharing the last token of its canonical name is always shortlisted. Its existence is the evidence, not its similarity.

#Regularity cannot catch a bad merge, so only high confidence merges silently

Two staples each bought every twelve days, offset by six and wrongly merged, produce a flat six-day interval: MAD 0, regularity 0. The merged staple looks like the most regular item in the dataset while being wrong by a factor of two, and the confidence gate will auto-add it twice as often as needed.

Nothing downstream can detect that, so the decision has to be conservative at the point it is made: only a near-certainty merges, and only onto a candidate that was on the shortlist. Anything less is left separate.

#The intelligence layer is gated on being configured, not attempted and caught

Every call degrades to null on failure, which is enough for correctness but not for behaviour: without an App Check site key the functions reject everything, so each item typed would fire one round trip that cannot succeed, log an error, and cost latency and battery in a shop with bad signal. Checking availability first turns "broken but handled" into "absent", which is a state the app was designed for from the start.

#App Check is enforced from the first deploy

An unprotected function holding a billable API key is someone else's free inference endpoint within a week. Enforcement happens at the platform, before any of our code runs, so an unattested call spends nothing. Adding it after the fact would mean a window where the key is exposed, and there is no way to know afterwards whether that window was used.

#The profile question is derived, not scheduled

The first version computed it once a month inside an effect and kept it in React state. A reload lost the state, and the once-a-month flag stopped it being recomputed — so an unanswered question got exactly one chance to be answered and then vanished until the following month.

nextQuestions is pure, deterministic and cheap, so it is now recomputed from the profile on every render: the question keeps its slot until the profile actually records an answer. Only the generative call is scheduled, because only it costs anything. The general lesson is that scheduling should guard spend, never derivation.

#The question bank terminates; item suggestions do not

This is the whole argument for asking about categories before suggesting items. There are perhaps fifteen household-defining facts — pool, pond, fireplace, yard, pets, septic — and once answered the questioning is finished forever. A "no" kills a dozen items permanently. Item suggestions have infinite supply, so a system built on them becomes a monthly advertisement no matter how good each one is.

The gate that suggestions may only come from confirmed categories is therefore stronger than a dismissal list: it works before the first bad suggestion rather than after it.

#Receipt seams are found by position, never by name

Two MILK lines on one receipt are usually two real purchases. Deduping by name would erase one silently, and a purchase that never existed is indistinguishable afterwards from one that did. So overlap between photographed segments is a run of at least three consecutive matching lines — position is the evidence, text is not.

Where no seam is detectable the segment is appended whole. That errs toward a duplicate, which the review step shows you, rather than a dropped line, which nothing does.

#Price is extracted, used, and discarded

Receipts carry their own checksum, which is the only reason to read prices at all. Keeping them would add a data liability and invite the price-tracking feature the spec cuts on purpose. So the subtotal reconciles the merge and is then thrown away.

#The review pass writes only to the suggestions queue

Letting it write to a staple directly would buy nothing and cost four things: traceability (which suggestion moved this cadence, and why), graceful degradation (a pass that never runs must break nothing), a free quality metric (accept-rate per type), and determinism containment — the same data on two runs can produce different output, which is tolerable in a queue and an unreproducible bug in the data.

Everything it proposes is validated deterministically first. A proposal naming a staple that no longer exists is not a suggestion; it is a crash waiting for somebody to accept it.

#Merge and split dismissals are permanent, not on a cooldown

Most dismissals mean "not right now" — dormancy, quantity, cadence — and deserve to be asked again later. A refused merge does not: "milk and almond milk are different things" is a fact about the household that will not change in ninety days, and re-asking is precisely how an app starts to feel like it is arguing with you.

#Tidy up is unbadged and last

Every item in that queue is the app asking the user to do maintenance on the app's own data model. Badging it inverts the relationship — the user starts working for the software. It sits last on the home screen with no count, and because the review pass only ever proposes, ignoring it forever costs nothing.

#Undo reverses the purchase, not just the list row

A check-off is three effects, and the visible one is the least important. Putting the item back while leaving the purchase behind would mean a mis-tap silently shortened that staple's interval — permanently, invisibly, and with nothing on screen connecting the wrong prediction to the tap that caused it. The purchase stream is the only thing cadence learns from, so it is the thing an undo has to reach.

The purchase document is deleted rather than marked void. An undo means the event did not happen; a voided record would have to be filtered by every reader in perpetuity, including the cache rebuild, to express the same thing.

#A location fix never blocks a check-off

getCurrentPosition's timeout bounds acquiring a position. It does not bound a permission prompt sitting unanswered — so awaiting the fix before opening a session could hang forever, and the thing it hangs is the tap that checks an item off. Found by testing a browser context with no geolocation permission granted, where the row simply never moved.

The session now opens immediately and the fix lands on it later, or never. Knowing which shop you are in is worth something; it is not worth a list that does not respond to being tapped.

#The row is two targets, and the box gets the oversized one

Tapping the name to edit is the expected gesture, but it costs the check-off control most of the row's width — and this is used one-handed, pushing a trolley, where a missed tap matters more than anywhere else in the app. The box therefore keeps a hit area wider than it looks.

Tasks and the low-stock shelf keep a single whole-row control: neither has anything to edit inline, so splitting them would shrink a tap target for nothing.

#Profile questions ask about purchases, never about objects

"Do you use a fireplace or wood burner?" was the first question the app ever asked, and the first user with a gas fireplace could not answer it. They own a fireplace and buy nothing for it, so yes produces firewood suggestions forever and no feels like a lie — and the app learns something false either way.

Every fact in the bank gates a shopping list, so every question is now about shopping: "Do you buy firewood?", "Do you buy pool chemicals?", "Do you change your own air filters?". Those have one honest answer, and it is the answer the suggestion needs. The questions that were already right — filters, grilling, clearing snow — were the ones that happened to be phrased this way.

A test rejects any question beginning "Do you have" or "Do you use", so the shape cannot drift back.

#The ambiguous case gets a sentence, not a third button

Adding "sort of" would need a fourth option next month and a settings screen by spring. The escape hatch takes a sentence instead and turns it into the same outcomes the buttons produce — which is exactly what contextual chat is for: a control that does not fit, not a chat product.

What persists is the outcome, never the exchange. And it is absent rather than dead when the intelligence layer is unavailable, because a control that cannot work should not be on screen.

The escape hatch has since been removed with the other individual chat entry points, and the question is back to yes and no: see "One chat surface: the drawer".

#The App Check checker proves enforcement with a negative test

A correctly attested app and a completely unprotected one behave identically from the outside, so "the app works" is not evidence of anything. Worse, the obvious probe — an anonymous call that gets refused — proves only that the auth check works, because requireAuth and App Check refuse with the same code.

The only test that distinguishes them carries valid auth and no App Check token and must still be refused. scripts/check-appcheck.mjs runs that, and the probe uses an empty candidate list, which resolveEntity answers before it reaches the model — so even a probe that gets through spends nothing.

#A checker must know when it did not observe anything

The first version of that script reported "deployed and answering" and "unauthenticated call refused" from inside a sandbox whose egress filter was returning 403 to every request. The call never reached Firebase, and the script turned a blocked request into a clean bill of health — the exact failure it exists to prevent, with more authority than a guess.

It now checks that a reply plausibly came from Google — JSON body, or Google's own edge headers — before reading anything into it, and reports inconclusive where it previously reported safety. A verification tool that cannot tell "I observed protection" from "I observed nothing" is worse than no tool, because people believe it.

#Structured-output schemas use anyOf for nullability, not type arrays

Structured outputs accept a restricted subset of JSON Schema. type: ['string', 'null'] is not in it, and neither are minimum, maximum, maxLength or maxItems. Every schema here used all of them.

The failure was invisible until the functions ran against the real API: the request is rejected with a 400 inside the function, which reaches the client as an opaque 500. Nothing in typechecking, unit tests or the emulator catches it, because the shape is only validated at the API. Bounds that matter now live in description, where the model reads them, and are enforced by the client when it writes the document. A test walks every node of every schema and rejects the banned constructs, so this cannot come back.

The wider lesson: prompts and schemas that have never been executed against the API are unverified, however green the suite is. Structure can be tested offline; acceptance cannot.

#Function failures are logged with their cause and returned as typed errors

Letting an SDK error escape a callable produces a bare 500 with the reason recorded nowhere. rethrow now separates the cases that need different responses: a 400 is ours to fix and no retry helps, a 401 means the secret is missing or rejected, a 429 or 5xx is transient. Each gets its own log line and its own code to the client.

This is what turns "it returns 500" into a one-line answer next time, and it is the thing whose absence made the schema bug take a round trip to diagnose.

#A blocked Firestore is silent, not an error

An ad blocker or privacy extension blocking firestore.googleapis.com produces ERR_BLOCKED_BY_CLIENT in the browser. The app's response was to show an empty list forever with nothing explaining why, which is the worst way for it to fail: it looks like it works and it has no data.

The obvious fix does not work. A Firestore listener that cannot reach the server does not call its error callback — the SDK treats a blocked network as ordinary offline, serves whatever the cache holds, and retries indefinitely. With an empty cache that means no callback at all. Verified by blocking Firestore in a real browser: onSnapshot's error handler never fired.

So the signal has to be silence rather than an error: we asked for data and heard nothing back while the browser insists we are online. src/data/health.ts implements that as a watchdog, and useConnection distinguishes three states — offline (expected in a shop, writes queue) from unreachable (online but the data never arrives, which needs the user to change something).

The first implementation shipped wired into all eight subscriptions and did not work, for two reasons that instrumenting the live path made obvious — both are now fixtures in src/data/health.test.ts:

  • The watchdog never armed. On a cold start with an empty cache the boot dies before any subscription exists: useHousehold awaits a getDoc in ensureUserDoc, the SDK rejects it ~10s in as unavailable ("client is offline" — while the browser is online), hid stays null, and all eight subscriptions hang off hid. noteListening was never called. The bootstrap read now arms the watchdog itself, and useHousehold retries the unavailable rejection on an interval rather than parking the app on an error message whose wording sends people to check their signal.
  • It measured the wrong silence. Unreachability was judged by time since the last snapshot, but a healthy idle listener is silent for hours because nothing changed — every quiet session flipped to "can't sync" eight seconds after its last data change. The clock now runs only while an ask is unanswered: from noteListening until the next noteSnapshot, restarting after an offline spell so a reconnect gets the same grace as a cold start.

Known limit, accepted deliberately: a block that begins mid-session, after every listener has been answered, is indistinguishable from idleness and is not detected until the next unanswered ask (in practice, the next cold start). Cache answers are not treated as proof the path is open where that is free (getDoc falls back to cache only when the server is unreachable), but listener snapshots served from a warm cache still count as answers — telling those apart needs includeMetadataChanges on every subscription, a cost the empty-cache failure mode does not justify.

#Suggestions land where their category shops — and some are not purchases at all

Seasonal suggestions all landed on the grocery list, because the "Coming up" strip lived there and "Add" wrote to the list on screen. Overseed and HVAC filters arriving at the food shop read as the app not knowing what the things are: they come from the hardware run.

The routing is deterministic and lives in the question bank: each category carries a destination list. The model labels an item with a category; where that category is bought is a fact about household shopping that no phrasing changes, so it is never the model's call. The strip renders on both lists and each suggestion surfaces on the list it belongs to, which also keeps "Add" free of routing — the list you are looking at is where it goes.

Flea and tick treatment exposed the second assumption: not every suggestion is a purchase. It comes from the vet in one household and off a shelf in the next — which it is is a fact about the household, not about the item, so it is a fourth button ("it's a task"), never an inference. Choosing it creates a task and dismisses the name as an item suggestion, because the task owns it now; the dismissal is visible and reversible in Settings.

#The undo clock runs only while someone could see the bar

The first check-off of a trip does two things at the same instant, by design: it mounts the undo bar and it raises the location permission prompt (the fix is requested at first check-off, when there is something concrete to point at). The prompt steals focus; the eight-second undo expired underneath it; and the first undo a new user was ever shown was gone before they declined the prompt. The feature had technically been offered — and was reported, correctly, as missing.

Three changes, one principle: an undo that cannot be seen is not an undo.

  • The bar's countdown pauses whenever the page is hidden or unfocused — a permission prompt, an app switch, a locked phone — and resumes with at least a beat and a half remaining, so it cannot vanish the instant attention returns.
  • The bar is pinned to the viewport instead of placed inline, where a mis-tap halfway down a long list rendered it somewhere off screen.
  • Session writes are no longer awaited before the tick lands. Offline they never acknowledge at all — with a warm cache the first tick of a trip hung forever — and online they put a round trip between the tap and the undo appearing. The session id is client-generated and Firestore preserves write order, so nothing downstream needs the ack.

#Checked items stay completed for the length of the trip, not vanished

Ticking a row used to remove it instantly, leaving two problems the transient undo bar cannot fully solve. Mid-shop, "did I already grab milk?" is a real question, and a vanished row cannot answer it. And the bar, however visible, is still a window — the way back from a mis-tap should not be a race.

So the current trip's check-offs stay below the list, struck through, and a tap puts one back. Three calls inside that:

  • "This trip" is the session-gap chain, not a cutoff. The newest check-off must be fresh and each earlier one joins by being within 75 minutes of the one after it — the same rule and constant that end a session, so there is one definition of "a trip" in the app. A two-hour shop keeps its first item; yesterday's shop contributes nothing; the section empties itself with no timer and no cleanup job.
  • The late restore reverses the purchase without a receipt. A check-off stamps the item and the purchase with the same millisecond, so at === checkedAt identifies the pair exactly — equality filters only, no composite index, served from the local cache offline.
  • The staple's status is left alone. Deleting the purchase and pruning the cache is what protects cadence; a status nudged by the mis-tap drifts back on its own, and restoring it late could stomp a change someone made on purpose in between.

#A pantry photo proposes; only a person tracks

The photo intake could have created staples directly — the model was already told to omit guesses. It does not, for the same reason low confidence never auto-adds: a wrong staple on the staples screen costs more trust than a missing one costs convenience. Every item comes back behind a checkbox, already-tracked names are disabled rather than hidden (seeing them is how you know the photo worked), and nothing in the flow ever writes a purchase — a photo shows what you have, not what you buy.

Profile hints are narrower still: a photo can only answer an unasked question, and only with yes. Presence on a shelf proves presence; it proves nothing about absence, and it never overrides an answer a person gave. The staple arrives estimated with the shelf location as its prior's category, so the estimate is labelled and fades the moment real purchases exist.

#A receipt records purchases; it never creates staples

Receipt lines match staples locally only — the sourceNames dialect cache and the canonical name — with no model call per line. The consequence is accepted openly: the first receipt from a store matches almost nothing. But every line a person records caches its dialect string on the staple, so the second receipt from that store matches itself. The cache builds from confirmed decisions, the cheap layer stays cheap, and nothing is ever matched by a guess.

Recording groups chosen lines per staple into one purchase with the summed quantity. The seam merger deliberately keeps duplicate lines (two MILK lines are two real units, and positional merging must not lose one), but recording them as two same-day purchase events would hand cadence a burst of zero-day intervals; one event with qty 2 says what actually happened — one trip, two cartons. A line whose staple already has a purchase on the printed day is marked "already recorded" and skipped by default, since the likeliest cause is the trip having been ticked through live.

Unmatched lines are shown, never actioned. Creating staples from dialect strings would name them GV WHL MLK GAL forever, and inventing a prettier name client-side is the model's job in the review pass, where a person accepts or dismisses the proposal.

#The receipt's first job is filling the list (a revision)

The first version of the receipt flow centered recording purchase history and held every photo hostage to its own model read; unmatched lines were "shown, never actioned". Real use inverted the priorities: what a person mostly wants from a receipt is to not retype what they just bought, and waiting between shots made a three-part receipt feel broken.

Three revisions, and what each preserves:

  • Shooting never waits. Photos are accepted instantly; reads run in the background in parallel, and a failed part retries from its stored image. The seam still merges in shot order, so nothing about the positional merge changed.
  • Every line can be added to the list. The earlier rule said unmatched lines are never actioned — that rule was about staples, and it stands: a staple named GV WHL MLK GAL forever is still the thing this refuses to create. An item is different: cheap, visible, deletable, and exactly what the promotion machinery is designed to judge. The receipt fills the list; the list's own three-adds rule decides what becomes a staple.
  • Lines carry two names. The model now returns the generic name a person would write ("whole milk") beside the verbatim register string; the screen shows the human one (a local code-stripper covers older deployments), and the dialect string stays underneath as the cache key. Inventory codes are the till's business.

The checksum demoted itself in the same pass: still computed, still shown when it disagrees, no longer dressed as a warning. It exists to catch a missing strip between two shots, not to make a shopper do bookkeeping.

#A looked-up fact lands in the task's details, not the chat reply

The ask box on the Tasks screen exists for one move: "what size filter does our furnace take? add it." The model's answer could live in the reply, but the reply evaporates by design — no thread, no scrollback — and the fact is needed at the store, not at the moment of asking. So the chat actions grew note: add_task creates the to-do with the answer in its details, and annotate_task attaches one to a task already on screen. The outcome-not-transcript rule is what makes this safe to keep stateless: everything worth keeping has a document to live in.

Two guards, both deliberate. annotate_task matches by normalized title against the open tasks the model was shown, and a near-miss writes nothing — a wrong annotation on a stranger's task is worse than a lost one. And the model answers from what it knows: standard sizes and compatibilities are real knowledge, a specific furnace's spec is not, and the prompt tells it to say in the reply what to verify rather than dress a guess as a fact. The box is scoped to tasks for now, on request — items may earn the same later.

The near-miss guard has since been softened: see "An unplaceable note becomes its own to-do". The statelessness has since been narrowed to one turn: see "One turn of memory, and only in the ask box". The box itself has since been removed; the looked-up fact still lands in the task's details, now from the drawer: see "One chat surface: the drawer".

#An unplaceable note becomes its own to-do

A near-miss on annotate_task wrote nothing, which was right about the danger and wrong about the remedy: the reply still said the fact had been filed, so the failure was silent and total. A fact the user watched the app accept has to land somewhere, so it becomes its own to-do carrying the note. The wrong shelf is recoverable in a tap; gone is not recoverable at all, and the user has no way to even know it happened.

The ask box has since been removed; the fallback lives on in the drawer's annotate_task: see "One chat surface: the drawer".

#One turn of memory, and only in the ask box

"What size filter does a Carrier 59SC5 take?" followed by "add it" is the ordinary way people use the ask box, and statelessness made the second sentence unresolvable — the model, given no referent, would answer as if it had done something. Perfect statelessness produced a confident lie, which is worse than the state it was avoiding.

So exactly one turn is carried: the previous message and the previous reply, held in a ref for the life of the screen and never stored, never shown, never sent to Firestore. Leaving the tab forgets it. The outcome-not-transcript rule is untouched — what persists is still only the document a write landed in.

One turn rather than a thread because one turn is what a referent needs. A growing history would turn a box that resolves a single thing into a chat product, which is the thing this seam was built not to be, and it would make each call cost more for a screen most people open once a week.

The escape hatch on the list screens deliberately keeps none. It answers one question and closes itself, so a carried turn there would only bleed one question's context into the next.

The assistant drawer added later keeps a real thread; the ask boxes have since been removed outright — see "One chat surface: the drawer".

#The auto-add sweep treats this trip's check-offs as still on the list

Ticking an item off did not remove it. The reported symptom was that the check-off asked for location, showed the undo bar, and left the row where it was — and the location prompt is the clue rather than an aside.

Three things line up. A check-off writes a purchase; the purchase moves nextDueAt to a new cycle; and a new cycle is precisely what releases the lastAutoAddedForDueAt guard, by design, because that is how a staple gets offered again next time. So the only thing left standing between checking an item off and being offered it again was the lead window — and for any staple due again inside its own lead, the window covers the moment of purchase. Milk on a two-day rhythm against the two-day fallback lead is the plain case; anything whose store is visited about as often as it runs out is the same case with the rhythm setting the lead instead of the fallback. Meanwhile the sweep that does the offering runs on every return to the foreground, and the first check-off of a trip raises the location permission prompt, which takes the app out of the foreground and hands it straight back. The item was on the list again before the undo bar had expired, and no amount of ticking could clear it.

Widening "already has an unchecked item" to "already has an unchecked item, or was ticked off on this trip" is the fix. It costs nothing anywhere else, and it makes the sweep agree with the screen: the list already keeps this trip's check-offs visible under "Checked off", by the same tripChecked chain and the same 75-minute constant. Something the app is still showing you as done is not something to put back on the list.

Capping the lead window against the cycle it leads was tried first and is wrong. It fixes the same symptom and breaks a deliberate behaviour on the way: a long lead is the correct answer when trips are rare, and an item due in four days when the next trip is in five belongs on the list now. There is no honest fraction of a cycle to cap at — the cheap failure is the premature item, the expensive one is the empty shelf — and the real defect was never in the arithmetic. It was in the sweep forgetting a purchase it had just watched happen.

#Where a thing is bought is learned, never filed

Asked for a way to assign grocery items to a shop, and then, plainly: "I don't want to HAVE to pre-populate items. I want the app to learn over time and do it for me." That is the requirement, and it rules out the obvious design — a shop field on every item, filled in by hand — before it is built.

The record was already there and never read. A purchase carries the trip it happened on; a trip has somewhere to record its shop. Nothing populated it, nothing displayed it, and nothing could correct it, so the grouped list every screen was built around had exactly one group and no way to get a second.

So the trip is the unit. One tap on the shop in the list header re-attributes every item ticked off on that trip at once, because the purchases point at the session and the session owns the shop — one write, nothing rewritten per item, and it works just as well an hour later as it does in the aisle. That is also the multi-select that batching this by hand would have needed: a trip already is a selection of everything bought on it.

An item's shop is then derived and never written. staple.store keeps its old meaning, a shop pinned by a person, and everything else is computed on each render from the purchases. This is not a performance choice. It is what makes a manual pin permanent without a guard protecting it: inference cannot overwrite a pin when inference writes nothing at all. It also means correcting a trip is felt immediately, with no sweep to re-run and no stored answer to migrate when the rule changes.

The rule is the dominant shop of the newest five purchases that have one, needing at least three and a strict majority. Three at one shop files it; shopping wherever leaves it under Anywhere; one odd trip does not unfile it; and a 2-2 split answers nothing rather than picking whichever the iteration reached first, because two phones holding the same data must not disagree about the list. The window counts purchases that can say something rather than raw entries, or a run of receipt imports with no trip would push the real evidence out of view.

The marker for a pin is overriddenBy.store, not store !== anywhere. Pinning something to Anywhere on purpose — "I buy this wherever, stop filing it" — has to stick, and by value alone that is indistinguishable from never having pinned anything.

#A shop is created by shopping, not by being set up first

Nothing could create a shop. createStore was reachable only from the picker that resolves an ambiguous fix, and a fix is only ambiguous between two shops you already have — so with none, you could never get one. resolveNearbyStore and its documented API key had no caller at all. A settings screen for adding shops would have fixed the mechanics and missed the point: it is setup, and the ask was explicitly for less of that.

Instead the first check-off somewhere unrecognised offers to save the place, at the fix that check-off already took, so the shop has coordinates from the moment it exists and every later visit is arithmetic against a point already held. The Places lookup only pre-fills the name; without a key it is a blank field, which is the same path one keystroke longer. Settings lists shops for renaming and deleting — tidying what shopping produced, not a step to complete first.

inferStore gained a fourth answer for this. Outside the 150 m match radius but inside two kilometres, the nearest shop is proposed rather than the trip being left unattributed: a fix taken inside a large shop is routinely hundreds of metres out, so that reading is far more often a bad fix at a known shop than a trip somewhere new. It never becomes a silent match — it is filled into the header where one tap corrects it. Deciding between them is the difference between costing a tap and costing the whole record of where a trip happened.

#A shop is identified by its place id, not by its name

The first cut of this asked "where are you shopping?", took a name, and saved it against wherever the phone reported. The Places lookup it called was already there and its answer was thrown away except for the name — no id, no address, and the storefront's own coordinates replaced by the fix. The result was a label at an approximate point, which cannot answer the question the feature exists to answer: which Kroger. A household with two of them had two shops both called Kroger and nothing to tell them apart.

So the id is the identity. Names collide legitimately, and coordinates drift with whatever fix happened to create the record; the id is the same string every time Google is asked about that storefront. It is what makes findByPlaceId a reliable dedupe — saving one shop twice would split its visit history, and that history is what the trip cadence behind lead time is built from — and it is what lets a lookup answer the ambiguous case exactly, where a coarse fix never could.

The address rides along because a place id is not something a person can read. A picker that offers "Kroger, Kroger" is no better than one that offers "Kroger".

#The lookup decides whether you are at a shop at all

Reported while sitting at home: ticking things off hours after the trip. That opens a new session at the house, and the nearest branch — which exists because indoor fixes are routinely hundreds of metres out — would silently attribute it to any known shop within two kilometres. For most households that is their supermarket. The app would have recorded a shopping trip that did not happen, and then learned from it.

Proximity cannot fix this. A bad fix inside Kroger and the kitchen table three streets from Kroger are the same reading. The lookup is the only thing that can separate them, because "is there a shop where I am standing" is exactly what it answers, and a house is not a shop. So nearest and none now both require the lookup to confirm somewhere shop-like before anything is recorded, and record nothing when it does not — no attribution, and no offer to save your own kitchen as a shop.

Recording nothing had to become a first-class outcome for this. It is not a failure state: the trip is still there, still correctable from the header chip and from Recent trips, and an unattributed trip teaches the app nothing, which is strictly better than teaching it something false. Silence over noise, the same rule auto-add is built on.

The whole table lives in attributionFor as a pure function rather than inside the session hook. It is a decision with eight branches that depends on a network call, a geolocation fix and a Firestore snapshot; as a fixture it is eight assertions, and as a hook it would be reproducible only by standing in a car park with the right API key.

#Attributing a trip is a Settings screen, not a longer window

The obvious answer to "it's too late, I'm home" is to keep the trip open longer. That is wrong twice: the 75-minute gap is what defines a trip for store inference and lead time, so stretching it to cover unpacking would merge two shops into one session; and it only ever moves the cliff rather than removing it.

Listing recent trips instead removes it entirely. Purchases point at their trip and the trip owns the shop, so attributing one is the same single write whether it ended ten minutes or three weeks ago — nothing is rewritten per purchase and nothing has to be found again. The listener over recent sessions already existed to feed the shop learner, so this is a screen over data the app was already holding.

Trips that bought nothing are hidden. A session with no check-offs is not a trip anybody remembers taking, and a list of them would bury the ones worth attributing.

#Regulars is a Settings subscreen, not a tab

Four permanent destinations is the whole bottom bar, and Regulars was the weakest of them. It is a ledger you consult — "what has it learned about how often we buy milk" — not somewhere you go while standing in a shop. Nothing on it is ever urgent, by design: no badges, no overdue, no red. A destination you visit out of curiosity does not earn a quarter of the bar.

Notes does earn it, because it is used in the moment: you write down the model number while looking at the boiler.

/staples, /staples/photo and /staples/:stapleId redirect rather than 404. An installed PWA holds its own history, a bookmark outlives a rename, and a link already sent to a housemate cannot be recalled. The redirect is four lines and removes an entire class of "it used to work".

The photo intake moved with it to /settings/regulars/photo even though its own back target is Lists — it is reached from the + in the bar, not from Regulars. That is a slight mismatch, kept because the route naming the screen it files into is worth more than the route naming where you came from.

#Notes search is substring matching, not the resolver

canonicalize and trigramSimilarity were both already in layer 1 and both are wrong here, in ways that only show up on real notes.

canonicalize is built for grocery names: it strips quantity words and units and singularizes. Pointed at prose it destroys the search — a recipe holding "300g strong white flour" stops being findable by "300g", and "berries" no longer matches the word somebody actually typed.

trigramSimilarity ranks short entity names. Across a multi-paragraph body the trigram set dilutes, so a note gets less findable the more you write in it, which is exactly backwards for a notes app.

So: substring over folded text — lowercased, combining marks stripped, whitespace collapsed. Folding is what makes "cafe" find "café", since a note typed on a phone and a query typed in a hurry rarely agree on accents. Every whitespace-separated token must match, but tokens may match different fields, so "boiler worcester" finds a note titled "Boiler service" whose body mentions the model. Treating the query as one literal phrase missed that, which is the fixture that pins it.

#Archived, not deleted — and one listener carries both

Notes are the one thing in this app with no natural end. A task completes, an item is bought, a trip finishes; a note just sits there being the only copy of the boiler's model number. Deleting one on a mis-tap is unrecoverable in a way nothing else here is, and the self-expiring undo bar is the wrong shape for it — it is tuned for a check-off in a shop, where ignoring it means accepting, and its label is check-off specific.

So archivedAt, a shelf on the screen, and a permanent delete offered only from inside that shelf. Nothing is ever one tap from gone.

One listener carries the whole collection, archived notes included, and the split is client-side. The shelf needs those documents anyway, and a where clause here would buy nothing while costing the empty firestore.indexes.json that the rest of the app maintains.

Archiving and restoring deliberately do not touch updatedAt. It means when the content last changed; bumping it would float a three-year-old note to the top of "recently updated" the moment it came back, and the order would stop meaning anything.

#A markdown renderer is worth a dependency; writing one is not

Superseded — see "Notes are plain text, and the editor is the note" below. The reasoning about which renderer to pick still holds if formatting ever returns; what changed is that notes stopped needing one at all.

Notes needed formatting, which forced the question this repo had so far avoided.

Hand-rolling a subset is tempting and was the wrong call. Safe, yes — returning React elements has no injection surface — but "complete" was the requirement, and a hand-rolled subset is a permanent negotiation about which constructs exist.

marked + DOMPurify is the smaller dependency and the worse trade. It produces an HTML string, which means dangerouslySetInnerHTML — the first in this codebase — and a sanitizer whose configuration is then security-critical forever. Fewer bytes, more ways to be wrong.

react-markdown builds React elements. There is no dangerouslySetInnerHTML beneath it, so there is nothing to sanitize and no sanitizer to misconfigure. Raw HTML is inert unless rehype-raw is added, which it is not, and defaultUrlTransform already refuses javascript:. urlTransform is left alone: overriding it is the documented way back to an XSS hole.

It is the heaviest thing in the bundle — 154 kB, 46 kB gzipped, against a main chunk of about 1 MB — so NoteMarkdown.tsx is its only importer and loads through React.lazy. Measured, the split holds: the main chunk grew 9 kB. The Suspense fallback is the raw source rather than a spinner, because unrendered markdown is still the sentence somebody wrote, and a note has to be readable before the chunk lands — or if it never does, which is what a first visit on a bad connection looks like. Note that workbox precaches every emitted chunk, so the split buys first-paint cost and not total bytes fetched.

This narrows the no-libraries posture rather than abandoning it. That rule was always about state — a second source of truth beside Firestore's listeners — and about not pulling a CSS framework for six screens. The bar it sets for a runtime dependency: it does something correctness-critical that we would otherwise do worse. A parser and its escaping rules qualify. A store does not.

#TaskDoc.notes keeps its name

A notes collection now sits beside a notes field on tasks that means something else — a single detail line, which completing the task destroys.

Renaming the field would be a migration across every task document already written, for a naming tidy with no user-visible effect. Not worth it. The UI has always called it "Details", so the collision exists only in the source, and the ## Notes section of ARCHITECTURE.md opens by naming the distinction so the next reader meets it before tripping over it.

#Settings is three groups and three subscreens, not seven cards

It had grown to seven <section> cards — Household, What the app knows, Recent trips, Shops, Not for me, Learned, Account — each with the same raised surface, the same border, the same uppercase heading. Nothing was primary because everything was. On a new household three of them were paragraphs explaining they had nothing in them, so you scrolled past two apologies to reach anything real.

Anything with a screen's worth of content got a screen, following the Regulars precedent: /settings/shops, /settings/trips, /settings/knows. What is left is four rows saying where they go and what they hold, the two household facts anyone can actually change, and Sign out.

Nav rows rather than .cards. A card promises a destination worth a card — the home screen's lists earn one. A settings row is a door, and four doors in one card read as a menu, which is what this screen is.

The shelves went with them. <details className="shelf"> around the fact ledger existed because the ledger was competing for room; on a screen that is the ledger it is a shelf inside a cupboard.

#The household section was inert, and it looked it

.settings-name was font-weight: 600; font-size: var(--text-lg) and nothing else — a <button> styled to look exactly like a heading. The name was editable the whole time and no one could tell. The rest of the section was two constants: memberUids is always [you] because the invite flow is deliberately unbuilt (§13), so "Just you" could never say anything else, and the signup year never changes.

Now it is two rows with a label on the left, the value on the right and a chevron — the same shape as every other row on the screen. What could not vary is gone.

#Region is free text, and the app stops hedging

region was the last field in the schema with a reader and no writer. Both callable seams already sent it: functions/src/index.ts:159 sends Region: unknown — if you must give a season, say so with low confidence, and :241 sends Region: unknown. Every seasonal prediction was hedged because nothing could ever set it.

Free text, not a picker. The value is prose bound for a model prompt, and the useful granularity varies — "the Pacific Northwest", "Scotland", "Melbourne", "high desert, 5000ft". A picker needs a climate taxonomy this app does not own and cannot get right outside its own country; country codes are useless, since the US alone spans five zones.

Empty stores null, never ''. read.ts's str() is total and returns '' for a missing string, so a cleared region would read back as region: '', pass the !== null check, and send a literally empty Region: — worse than the ?? 'unknown' fallback it was built with.

Collapsed to one line and capped on the way in. The prompts are newline-delimited key: value blocks, so a multi-line value could forge the lines around it. The blast radius is genuinely small — single-tenant household, the only writer is a member, output is schema-constrained JSON, and useSeasonal re-filters by the real confirmed categories client-side — so this is hygiene rather than a security control. It is worth one line anyway: the delimiter format should be safe by construction, not safe because a downstream filter nobody remembers is load-bearing.

#A stated count is information; a badge would be pressure

The Recent trips row says "2 without a shop" when trips are unattributed. That sits close to a rule this app takes seriously — nothing is ever late, no badges, no guilt — and HomeScreen records the reasoning for Tidy up: "every suggestion is the app asking you to do maintenance on ITS data model; nagging about that inverts the relationship."

The line drawn: the count is stated in the same muted voice as "3 visits" and "Where you buy it", never a colour, never a pill, and absent at zero. It is phrased as a state of the trips rather than an errand owed — "2 without a shop", not "2 to assign".

It is also computed by listedTrips in layer 1, the same function the trips screen lists from. Counting sessions directly would have counted trips that bought nothing, which the list hides — you would read "2 without a shop", tap through, and find one. Two screens disagreeing about a number is worse than either number alone, so the rule lives in one place with a fixture.

#Notes are plain text, and the editor is the note

Notes shipped as a title field, a tags field, and a markdown body behind an Edit/Done toggle. Three of those four were wrong, and using it made that obvious in a way designing it had not.

The toggle was the worst of it. A note is a thing you write, and the app made reading the default and writing a mode you had to enter. Now the note is a textarea filling the screen, live from the moment it opens. New makes an empty one and drops you into it with the keyboard up: opening a note is an intention to write, not to look at a blank page.

The title stopped being a field. It is the first line, the way it is in Apple Notes, so naming a note is a side effect of writing the first thing you were going to write anyway. Title and body are still stored apart — the list row, the A–Z order and the search all want a title without parsing a body — so noteText / splitNoteText are the seam, and they are exact inverses. That property is what makes committing on every blur safe, and it is what carries notes written under the old shape: their title simply opens on line one. Nothing migrates.

Markdown went with the toggle. Once the editor is always live there is nowhere for a rendered view to live, and the honest options were a preview control nobody would use or a renderer that never renders. react-markdown and remark-gfm came back out: 154 kB, 46 kB gzipped, and the only lazily-loaded chunk in the app. The entry above is kept rather than deleted, because the argument it makes — that a parser and its escaping rules are worth a dependency and a hand-rolled subset is not — is still the right one if formatting ever comes back. What changed is the requirement, not the reasoning.

Tags went because search already worked. A taxonomy a household has to maintain is the first thing to go stale, and "diy" on three notes out of forty is worse than no filter at all. Substring search over the title and body finds a note by anything it actually says. NoteDoc.tags is not migrated away; documents written by the old build keep the field, and nothing reads it.

#An uncontrolled textarea, on purpose

A controlled one round-trips every keystroke through Firestore's local callback and back into value. On a short input that is invisible; on a note long enough to be worth keeping, it is enough to move the caret under someone mid-sentence.

So the textarea is seeded once per note with defaultValue, keyed by id so switching notes reseeds it, and committed on blur. onChange exists but feeds only the header's title, never the textarea's own value — otherwise the header would read "Untitled" over a note whose first line plainly says otherwise, until you happened to tap away.

Its height is computed rather than flexed. .container and body both size to their content, so there is no height for flex: 1 to divide; the editor subtracts --screen-head-h, --tab-bar-h and the container's own padding from 100dvh instead. dvh rather than vh because a phone's visual viewport shrinks when the keyboard appears, and vh would put the last lines under it. --screen-head-h is new, and exists for the same reason --tab-bar-h does: anything computing around the header should read one number rather than guess it.

#The row X archives, it does not delete

Everywhere else in this app the X on a row means gone: an item, a task, a shop. A note is different, because it is often the only copy of something — the model number nobody wrote down twice. So the X archives, the shelf restores, and a permanent delete is offered only from inside that shelf. Nothing here is ever one tap from gone.

#The list ask box may change the list, and a miss changes nothing

The ask box on the Tasks screen could only add. On a shopping list that is half a feature: the sentence people actually say in a shop is "we already got the milk, drop the yoghurt, add sourdough", and a box that could only do the last third of that would be a worse control than the row it sits above.

So the list box gets remove_item, check_item and rename_item, and the rows in view go into the prompt so it has something to act on.

The whole safety of that rests on one rule: a name that matches no row does nothing at all. Destructive actions resolve against the rows the model was shown, by canonical name — the same match annotate_task uses, with the opposite failure behaviour. A missed annotation falls back to writing its own to-do, because wrong shelf beats vanished. A missed removal has no fallback, because taking away a row nobody named is not a wrong shelf; it is their data gone on a guess. The prompt is told this too, so the reply says it could not find the thing rather than reaching for the nearest row.

planChatActions carries that rule and the fixtures pin it, including the case that matters most: a remove_item naming something not on the list returns no outcome, and a remove_item arriving on a surface that supplied no rows — the Tasks screen — cannot reach a list nobody is looking at.

The list ask box has since been removed, and the destructive verbs went with it rather than moving to a surface without the rows in view: see "One chat surface: the drawer".

#A model-driven check-off writes a real purchase

Checking an item off is not a UI action in this app. It writes a purchase document, and purchases are the only thing the cadence engine learns from.

The tempting safe answer was to let the box remove the row without recording anything. That is a lie of a different kind: you did buy the milk, the app is built to learn how often you buy milk, and quietly not learning it makes the box a worse version of the tap it replaces.

So a check-off from the box goes through exactly the path a tap does — purchase written, cadence fed, undo offered — and the risk is answered in the prompt instead. "We don't need milk" is remove_item; "we already picked up milk" is check_item. Only the second is a purchase that happened. That distinction is spelled out for the model because getting it wrong is silent: nothing looks broken, and the interval quietly shifts.

The list ask box has since been removed; nothing model-driven checks off any more: see "One chat surface: the drawer".

#One undo bar for the whole turn

"Drop the yoghurt and tick off the milk" is one sentence. A bar per action would mean two bars racing each other's timers, and undoing half of what you just said is not what anybody means by undo.

So the bar covers the turn: every reversal the turn produced, run together. It sits in its own state beside the check-off bar rather than sharing it, because a tapped check-off and an ask-box turn can be in flight at the same time and one must not overwrite the other.

A removal is reversed by re-creating the document with the same id and the fields it carried. A fresh id would look identical on screen and be a different row underneath — one that has forgotten its staple and the trip it belonged to, which is what the purchase stream is keyed on. tests/emulator/data.test.ts pins that: delete, restore by id, and the reader gives back exactly what went in.

Adds get no bar. An unwanted row is already one tap from gone, and a bar for every sentence would be noise.

#The calendar stamps completion, not attempt

The seasonal pass runs monthly and the review pass weekly, each behind a localStorage stamp. The stamp used to be written before the call — which prevented double-firing, but the seam is null-total: offline, out of quota and a model decline all come back as the same null, and the null path returned silently. One offline moment at the wrong instant burned the whole month.

So the stamp moved to the other side of the null check. A completed-but-empty answer is {items: []} or {proposals: []}, never null, so result !== null means exactly "the call finished" — the only thing a calendar stamp should record. Double-firing is prevented by an in-flight ref instead, which costs nothing and does not survive a failure the way the eager stamp did. A build with the layer off short-circuits inside the seam before any network, so the retry-per-snapshot this allows only exists where a retry is wanted.

#Three vendor chunks, named by hand

The production bundle was one ~1 MB file, so any one-line fix invalidated the whole precache. It now splits into react, firebase and antd groups plus the app's own code, each on its own release cadence — the app changes weekly, the vendors do not, and content-hashed chunks mean an unchanged vendor stays cached across deploys.

The config uses rolldown's codeSplitting.groups, not rollup's manualChunks: on Vite 8 the rollup-compatible options are deprecated and — the trap worth writing down — silently ignored whenever codeSplitting is present, so a config that mixes them looks like it works and does nothing. The firebase group stays whole at ~630 kB; chunkSizeWarningLimit is raised past it so the size warning only ever reports growth, never the chosen shape.

#The + belongs to everyone, not to the model

The drawer behind the bar's + was gated on the intelligence layer because everything in it — the receipt scanner, the pantry photo — needed the model. The blank-note option broke that coupling: a note needs nothing but a keyboard, so hiding the whole + in a no-key build was hiding a capability the build has. The + now lives on the bar in every build; the two photo rows inside the drawer are the gated part, absent rather than dead exactly as before. The note row is disabled rather than hidden during the seconds household bootstrap takes — "absent rather than dead" is about what a build permanently lacks, and a boot state is neither.

#A thread in the drawer, one turn in the ask boxes

The outcome-not-transcript rule was written for controls that resolve one thing where the person is standing, and it still governs them. The assistant drawer is a different product surface, asked for by name: a place the household goes to ask, floating over every screen. For that surface a shared thread is the product, not the exhaust — "what did we decide about the filters?" is a question the drawer should be able to answer, and a per-device history would show each person half a conversation. So the thread lives in households/{hid}/chatMessages, on the device clock like every timestamp here, roughly the newest twenty kept and ten turns sent to the model: a referent window, not an archive.

The drawer's schema is adds-only — add_item, add_task, add_note — and that is the boundary that keeps the reversal honest. Destructive verbs need the row they act on in view, which is exactly what the list ask box has and the drawer does not; a remove on a guess is the one mistake that loses data. The ask boxes keep their one turn and their full verb set, unchanged.

It runs Sonnet rather than the shared Opus constant because a conversation pays for latency in a way one-shot labelling does not, and it may search the web because "look it up and land it where I'll need it" is the drawer's whole job — the looked-up fact goes into a task's details or a note, never only into a reply that scrolls away.

The ask boxes have since been removed, and the drawer's schema has since gained annotate_task: see "One chat surface: the drawer". The shared-thread half of this entry has since been reversed: see "The thread in the drawer is yours".

#Old to-dos become home to-dos in the reader

Splitting the to-dos into home and work needed every existing task to land on home. The migration tool for that already existed: the total reader. readTask defaults a missing list to home, so every document from before the field existed is a home task the moment it is read — no rewrite, no backfill pass, no version marker, and an offline phone that syncs a week later needs nothing done to it. The writer stamps the field on every new task, so the default only ever covers the past, never fresh data. The cost is that the default is permanent — an absent field can never mean anything but home — which is the same bargain every reader default here has already made.

#A false chip is worse than a missed one

detectFacts turns prose into offers — call this, map this — and a wrong offer is noise on every render of the note, while a missed one costs a long-press and a copy. So every rule is stated as what it gives up:

  • A phone needs seven to fifteen digits, and without a leading + it must be written in groups of six or fewer. What that gives up: a solid ten-digit run gets no chip — it is as likely a tracking number, and someone who means "call this" writes it grouped. +NN… is unambiguous and accepted solid.
  • The candidate run may not touch a letter, digit, dot, comma or currency mark, and admits no comma, slash or colon inside — which is what keeps 300g, 24i, Hallway_5G, Carrier 59SC5, $12,999.99, 24/7 and 14:30 out. Dates, year spans and ZIP+4 are refused by name.
  • A URL needs a scheme or a www. — a bare domain would chip half the filenames a note mentions.
  • An address needs a number, a capitalised street word, and a capitalised suffix, on one line. What that gives up: lowercase prose about roads, and every address style that does not end in an English suffix — "12 Rue de la Paix" gets no chip. That is the bargain, and it is deliberate.

The claim order — email, url, phone, address — is specificity order: user@www.host.com must mail, the digits inside a URL must not dial, and the address heuristic, the loosest, only gets what nothing stricter wanted.

#Detected facts become chips beside the page, not a rendered view

The markdown removal argued "there is nowhere for a rendered view to live" once the editor is always live, and that is still true — you cannot linkify the inside of a textarea. The chips are not a view of the note; they are actions the note happens to contain, living beside the writing surface, which is what lets the textarea stay always-editable and uncontrolled.

The strip scans the draft live, through the same display-only state that already fed the header's title — nothing is ever written back to the textarea, so the caret rule holds. Live rather than on-blur because a just-typed number that only becomes callable after tapping away reads as broken.

The strip is one fixed-height line that scrolls sideways — the app's first and only horizontally scrolling row, against the app-wide wrap habit, and on purpose: the editor's height is a subtraction of constants, so a wrapped strip would move the writing surface line by line as facts came and went, and hiding the overflow instead would silently discard a fact that was detected. One line, one token, all six reachable.

Nothing in the app linked outside it before these chips, so the policy is set here, in one component (FactLinks): tel: and mailto: are plain anchors — they hand off to the OS, and the dialler or mail sheet is its own confirmation, so the app adds none. Web and maps links open in a new context with rel="noopener noreferrer", so the installed PWA is never navigated out from under its own state. Maps links use the Google Maps ?q= URL — the one cross-platform form: an app link where a maps app is installed, a working page everywhere else; geo: is Android-only and maps.apple.com Apple-only.

#A row's facts live outside its buttons

A to-do's detail line renders inside the button whose tap edits it, and an anchor inside a button is invalid HTML that would steal that tap. So a detail's facts render as the row's own second line — the row wraps rather than restructures, keeping the three existing tap targets byte-for-byte — capped at two, because a detail line is one line and rarely holds more than the number and the place. .linkish, not .chip: under the note editor the chips are the only control in sight; inside a row, pills would compete with complete, edit and delete.

#The icon source lives in art/, not public/

Everything under public/ is copied into the build and precached onto every phone, and the icon source is a 1.4 MB PNG — more than the rest of the app together. So the source lives in art/app-icon.png and only the derived sizes ship: a 64px favicon and the 192/512 icons with the tile cropped out and its corners masked to transparency (they sit on tabs and launchers of any color), and the maskable and apple-touch icons as the tile full-bleed on black with no alpha, because those platforms cut their own shape and the art's own margins are the safe zone. Regenerating is any sharp-capable environment: crop to the tile's bounding box (threshold-scan the black field), then the two treatments above at 64/192/512/512/180.

#The map chip carries the city, however the note writes it

The first map chip someone actually tapped sent them to the right street in the wrong town: the address continuations required commas, and the two-line spelling — street, then "City, ST 62704" underneath — never carried its city at all, so the query was a bare street line the geocoder resolved wherever it liked.

The fix redraws the strictness boundary, not the philosophy. DECIDING that something is an address stays as strict as ever — a number, a capitalised street word, a capitalised suffix, on one line. What rides along AFTER that decision is now deliberately looser: capitalised words with or without commas, and the one line directly beneath when it is nothing but a city line carrying its admission ticket — a state abbreviation or a zip, which no sentence opens with. The two mistakes cost differently: a stray capitalised word in the maps query is junk the geocoder shrugs off; a missing city is a drive across town. The label still shows only the street line — the chip names the place, the query delivers it.

#Late exists, but is never marked

The original rule was stronger: nothing is ever late, anywhere, full stop. It came at a price the calendar made visible — a monthly task's pending slot whose day had passed simply vanished from view, which is not gentleness but a lie, and a work task genuinely can be late whatever the app declines to say about it.

So the rule is redrawn at the styling boundary. Late EXISTS: a pending occurrence whose day has passed stays on that day on the calendar, and a task row whose slot has passed states the plain date ("Aug 1") rather than the euphemism "now". Late is never MARKED: no red, no badge, no "overdue" label, no count of things you failed to do, and a past day on the calendar only goes quiet. The date is a fact; the guilt was the design flaw. What still does not exist is any isOverdue computation — nothing needs one to state a date, and the first one written would grow a badge by Tuesday.

#Dates are found, never guessed

detectDates claims what prose names and refuses what it would have to infer, under the facts scanner's charter — a false chip is noise on every render; a missed one costs a glance at the calendar. Each refusal is stated as what it gives up: bare m/d ("dentist 8/28" is the miss) because 1/2, 3/4 and 2/3 are how recipes write fractions and notes are where recipes live; two-digit years, because 3/4/5 is nobody's calendar; weekday names, because "next Tuesday" means different days to different speakers and a chip that resolves it lies to half of them; lowercase months, because prose mentions what may happen and a date capitalises its May; bare ordinals and bare months, because "the 28th" and "in August" name no day. Yearless month-name dates roll forward — the calendar is a forward-looking surface, and "Dec 25" written in January means the coming December — and the chip always shows the resolved day, so the one inference the scanner does make is never silent.

#A relative date is pinned in the text, not in a field

"In 3 days" is a different day every time it is read, and any anchor chosen for it rots: the note's updatedAt moves on every edit, so the day a phrase meant would quietly re-anchor under it. The fix writes the resolution into the words themselves — at save, "in 3 days" becomes "in 3 days (Aug 28, 2026)", and a phrase carrying a readable parenthetical is never re-resolved. The year is always included, because a yearless pin re-read after a later edit would resolve one year off, which is the exact drift pinning exists to end.

The text as the store was chosen over a schema field deliberately: it needs no migration and no rules change, it merges cleanly across offline devices because it rides inside the field the write already carries, and the person who disagrees with the resolution edits it with the keyboard they are already holding. Pinning runs only at commit — task save, note blur — never per keystroke, and it is idempotent, so saving twice writes nothing.

#Taking a day off the calendar unwraps it, and never deletes words

The calendar could put a day on and move it, and could not take it off. The only way back was to open the note, find the brackets and delete them by hand — a syntax the app writes but never teaches — so a day filed by a mistyped line, or filed and then thought better of, simply stayed.

What the gesture removes is the commitment, and the commitment is the brackets: uncommitDate unwraps them and leaves everything else exactly where it was. Deleting the phrase was the other option and it is wrong. A person holding an entry on the calendar is saying "this does not belong on this day", not "take these words out of my note", and most committed days are days their own sentence names — "call the vet [Sep 4]" — so deleting would leave a hole in a sentence nobody asked to edit. Unwrapped, the note still says what it said, the day keeps its chip because the note still names it, and typing the brackets back commits it again. The commitment is the app's; the words are the person's.

Two costs are taken deliberately. A day the app itself appended, rather than one the writer's sentence already held, is left behind as a bare mention — litter, but litter that came from acceptOffer appending a second copy of a day already in the text, which is its bug and not this one's. And brackets doing some other job around a date come apart like any others, a pasted "[Aug 28, 2026](url)" among them. That pair should never have filed a day in the first place: isBracketed is pure adjacency, and the fix belongs there, not in a special case here that would make removal unpredictable to keep one paste intact.

#One shelf, one stored word

Supersedes the first paragraph of "Groups are one namespace, and the built-ins are virtual", which kept the two spellings on the grounds that a rewrite bought nothing.

That entry was right about the rewrite and wrong about the conclusion. Tasks stored 'home' and notes stored 'personal' for the same Personal shelf, and taskListForNote translated the pair at the point of use. Harmless while the only question the pair decided was which door to draw. It stops being harmless the moment one group can name another as its PARENT: 'home' and 'personal' would be two different parents, and the same folder would sit in two places depending on which door you came through — the one divergence a single shared document exists to make impossible.

So the pair collapses, onto 'personal', in the READER. canonicalGroupId runs inside readTask and readNote, and nothing above layer 2 ever meets both words again. No document is rewritten, which is what the old entry was defending: a total reader's default is this app's migration everywhere, and a stored value — unlike the prose the bracket pass had to rewrite — can be read into existence.

Read-time, not render-time, and the difference is load-bearing. A client running an older bundle still writes 'home' from its own code, and no migration could ever stop it; only the reader can. A translation that ran on the way to the screen would leave the old word in the data to be compared later by something that had not been told.

'personal' won the pair rather than 'home', and the choice was free — nothing moves either way. It leaves /notes/personal naming the value it always named and /tasks/home saying a word the app no longer stores, which that route has effectively done since the day the names unified and it began reading "Personal" on screen. It still resolves, through the same function, because an installed PWA's history outlives a stored spelling.

What went with it: taskListForNote (now the identity), the surface branch in builtinCards, and the assistant's taskListOf/noteListOf pair — four hand-copies of one rule, replaced by one seam applied at three reads. The Cloud Function schema was left alone: it still offers both words, the model may answer with either, and the app folds whichever arrives.

#The + leaves the bar

The intake drawer earned its slot when the bar had four destinations and three intakes with nowhere else to live. The calendar made it five destinations, and a five-tab bar with a drawer squeezed into the middle is six things wearing five slots. The drawer's contents already had better homes: a blank note was a duplicate of the Notes screen's own New button, and the two photo intakes feed the shopping lists, so they now sit as quiet chips on the Lists home — where the shopping context is, gated exactly as the drawer gated them, chips and never cards so the smoke suite's card equality keeps proving the lists are only lists. The Calendar tab, like every tab, carries an icon and a word and no badge.

#The assistant is told what the app is

The assistant floats over every screen and is the obvious thing to ask "how do I…?" — and it could not answer. Its prompt described its own five verbs and the snapshot it was shown, and nothing else: it did not know notes had a grammar, that groups nest, that a photo can be read, or that the app never marks anything late. Asked about the app it was floating over, it guessed, which is the one thing worse than saying nothing.

So the prompt ends with a description of the app: every screen, the note markers, groups, photos, the calendar, people, tags, questions, Settings, what offline means, and the assistant's own limits. Plain prose in the present tense, kept to what a person can see and do — the model is being told what the app is for, not how it is built, and a description of the schema would be a worse answer to every question a person actually asks.

It is a claim, and claims drift. Two things hold it. The note markers in it are asserted against the blank note's own cheat sheet, in the same words, because the assistant is the second place somebody learns the grammar and two sheets that disagree teach a rule the app does not have. And CLAUDE.md puts the file under the same rule as ARCHITECTURE.md: a change to what a person can do changes this in the same commit. The prompt also tells the model to say when the document does not cover a question, since a confident wrong answer about the app someone is holding costs more than "I don't know".

It costs tokens on every turn, which is what prompt caching is for: the whole system prompt is one cached block now, and the read count is logged per pass so a silent invalidator — a timestamp, a tool list in a different order — is visible as a read of zero rather than as a bill.

#The reply is prose; the actions are a tool call

The assistant's answer was one schema-constrained JSON object — reply first, actions after — and the reply was streamed by reading its characters out of the forming JSON. That design is written up below as it was made, and it was right about everything except the one thing it could not see: free prose generated inside a grammar mask. "Fir enough!" for "Fair enough!", "definGil definition", "7-Elevenven" — dropped and duplicated runs inside words, always on a token boundary, and never once from any other seam, because every other seam emits enums and short labels. Then a second report with a screenshot: a stored reply reading ":", the model's own final output, in a turn whose history held the same question twice.

So the reply is ordinary text now, with no output format on the call, and the actions come back through a client tool — take_actions, strict, with the old actions array as its input schema. Nothing about the actions changed: they are validated by the API on the way in exactly as the structured output was, the planner reads the same shape, and the callable's final value is the same {reply, actions}. What changed is that prose is generated as prose.

Two choices inside that. take_actions is NOT terminal: the model may call it and go on writing, and the reply is the text around it — the prompt already forbids claiming an add without the action, and "done, milk is on the list" after the call is the natural confirmation. And a turn's reply is the last ROUND that said anything, where a round is the passes between reads: text written before a read_note was the model going to look, not the answer it came back with, while text written around a take_actions is one answer. collectReply is that rule, and the streamed snapshots follow the same arithmetic one level up, so the preview and the record cannot disagree.

The extractor goes with the schema it was built for — replyStream.ts and its prefix-property test — and streaming gets simpler for it: text deltas are text.

The screenshot also fixed three things the schema had nothing to do with. A reply with no letter or digit in it and no actions is refused as empty rather than stored. A question nothing answered is left out of the history the next turn sends, since sent back it was the same question in front of the model twice. And a failed turn's question now wears its failure — which one, in its own sentence — and a Retry, because two silent failures followed by a resend was exactly the shape the screenshot showed.

#The reply streams out of the schema (superseded)

Superseded by "The reply is prose; the actions are a tool call", above. Kept because the reasoning about a second unstructured call and about parse-and-defend still holds, and because the snapshot doctrine it produced survives unchanged.

The assistant was the one call a person sits watching, and they watched a spinner: one structured request, one JSON object back, nothing on screen until all of it existed. The obvious streaming designs each gave up something real — a second unstructured call for the prose doubles cost and can contradict the structured one; dropping the schema for tool-calls reopens every parse-and-defend problem structured output exists to close.

What made streaming free is an accident worth keeping deliberate: reply is the schema's first property, so its characters are the first thing the model generates inside the JSON. The function watches the object form and forwards just the reply's readable prefix as chunks — extractReply is the pure piece, and its one invariant is that a wrong character in a chat bubble is unrecoverable while a held-back one costs nothing, so every half-finished escape waits. Prose blocks written between web searches never parse as the answer object and stay silent; a search instead announces itself as its own chunk, because forty quiet seconds reads as broken.

The stream is a preview, never the record. The thread's persisted reply is written from the final parsed object, the same value a non-streaming caller gets; a failed turn discards whatever previewed and writes nothing. The seam stays total — null on any failure, streamed or not — so the layer remains an accelerant, never a dependency.

#A hang is not an outage, and long polling is a latch

The boot's retry loop only ever ran when a read FAILED. Some phone networks produce the other thing: a half-open stream the SDK never gives up on, where the gating read neither resolves nor rejects and the boot waits forever on a screen that says "Setting up your household…". Desktop on a clean network never sees it, which is what made it read as witchcraft.

So the boot reads run under a deadline, and the two failure shapes are kept deliberately distinct. A read that REJECTS proves the path answers — that is a block (an ad blocker, a filtering network), long polling would not help it, and it keeps the old 5s retry. A read that HANGS past twelve seconds counts a strike, and two strikes while the browser insists it is online is the signature of a broken streaming transport: the app latches experimentalForceLongPolling in localStorage and reloads once. The latch is sticky — a network that broke streaming once will break it again, and long polling costs a little latency, not a feature — and set-once by contract: enableLongPollingFallback answers true only when the latch is newly held, so a storage that throws or drops writes can never turn the reload into a loop. The timed-out read it abandons keeps running, so ensureHousehold carries a superseded guard checked before each create write — a late success may finish, but it may not fork a second household under the attempt that replaced it. And the fresh user document is written with merge and without householdIds at all: only arrayUnion ever creates that field, so no cache edge can erase the back-pointer that finds the household.

#The boot never dead-ends silently

The stuck phone showed "Setting up your household…" and a small "can't sync" pill, forever. Both were true and neither was an explanation — the person holding the phone cannot know that a content blocker, a private browsing mode, or a filtering network is the usual cause, and the app knew and did not say. Now the boot screen itself says it, in the same muted voice, only while the watchdog's verdict is unreachable — and offers exactly one action, "Try again now", which is an honest reload rather than a fake progress bar. The copy never says "offline": the browser says online, and sending someone to check their signal instead of their browser is the misdirection the connection indicator exists to prevent.

#Photos live in the bucket; the note keeps the receipt

A note's photos put bytes somewhere for the first time, and they go to Storage — while Firestore keeps one small receipt document per photo: the download URL, the object path, and the transcript once one exists. The URL is stamped into the receipt at upload, so every later reader on every device renders straight from an img src and never asks Storage a question; nothing client-side ever fetches bytes by hand, which is also why the bucket needs no CORS configuration at all.

The orderings are chosen by which failure is cheaper. Upload writes bytes first and the receipt second; delete removes the receipt first and the bytes after. Either way a crash in the middle leaves orphaned bytes — invisible garbage — and never a receipt pointing at nothing, which would be a broken image forever. A permanent note delete walks the receipts and takes the bytes with them, because Firestore does not cascade and an orphaned photo of a deleted note is one nobody can ever see or remove.

Authorization is a capability, not a membership check, and that was the second draft. The first asked Firestore for memberUids through the rules' cross-service get — correct in production, and dead under the emulator, whose bridge forwards the lookup unauthenticated so the Firestore rules deny it and every local upload fails. Rules the suite cannot exercise rot, so the gate became the path itself: a signed-in user plus three server-generated random ids nobody can enumerate (no list is ever allowed on the bucket), and the ids are learned only from the household's own Firestore receipts, which real membership rules do guard. Download URLs already worked exactly this way — the token is the authorization — so write and delete now share the shape reads always had. Writes stay capped at 2 MB, image/jpeg only. And without a configured bucket the camera button simply is not there — absent rather than dead, the rule everything optional here follows.

#A big photo is scaled on the way in, and a failure says which one it was

A 16 MB screenshot would not attach, and the note said "Photo didn't take". Both halves of that were wrong.

The upload path had always recompressed under the storage ceiling, so the size was never the problem — the DECODE in front of it was. createImageBitmap decodes at full size before anything gets to scale the result, and a few tens of megapixels is a few hundred megabytes of RGBA on a phone that has nothing like it to spare. The scaling was happening one step too late, every time. So the head of the file is read first — a pure header reader for the four formats that state their size — and the decoder is asked for the smaller bitmap through resizeWidth, which it honours during the decode. The big bitmap never exists.

Reading the header could have been wrong in a way that mattered, so it is written to be useless rather than wrong: unknown format, truncated head, impossible number, all answer null, and null means decode exactly as before. A HEIC still fails, because nothing here can decode one — but it now fails saying it could not read the image, rather than saying nothing.

Which is the second half. Eight causes shared one sentence: an unreadable file, an image too dense to store, a phone with no signal (Storage has no offline queue, unlike everything else in this app), a refusal from the rules. A person can act on those differently — crop it, wait, or nothing — and the one message that covered all of them told them which was the case exactly never. Now each says its own, and the toast carries "Try again", which is possible only because the file is kept: the old failure dropped it, so retrying meant walking back through the picker.

Two smaller things fell out of the same reading. The canvas is painted white before the draw, since JPEG has no alpha and a transparent screenshot was encoding to ink on black. And a null 2D context throws instead of being swallowed by an optional chain — it used to draw nothing, encode a blank photograph and upload it as a success, which is the only failure here that never even reported itself.

#Transcription is a button, not a default

Reading a photo costs real money and real seconds, and most photos are never read — the point of photographing the boiler manual is usually the photo. So nothing transcribes on upload: "Read text" sits on each photo in the viewer, runs on demand, and the result lands on the photo's receipt, where it is cached so the same photo is never paid for twice — every device reads the transcript from the receipt from then on. The transcript reaches the note body only through an explicit "Add to note", and it enters through the same commit path typing uses, so date pinning and the no-op check apply to pasted text exactly as to typed text. The callable is also the one that fetches its own image: the client names the photo and the server downloads it after checking household membership — a phone on shop wifi sends a hundred bytes, not half a megabyte it already uploaded once.

And the cache is a person's to invalidate, never the app's. Paying once and caching forever quietly cost the only way back from a reading that was wrong: "Read text" is gated on the transcript being absent, so the moment one landed the button that produced it was gone, and a model's guess at a handwritten number was permanent unless you deleted the photograph. So the footer offers "Edit text" and "Clear text" beside it. Editing writes the words a person typed onto the receipt, where every device reads them from then on — the transcript is what the photo says, and who said so is not a distinction this app keeps. Clearing writes null, which is what brings the read button back: the three states are load-bearing — null is unasked, '' is asked and nothing readable, anything else is the text — so a correction emptied by hand clears rather than writing '', because a person deleting every word is taking the reading back, not reporting that the photograph is blank. Nothing re-reads automatically and nothing is paid for twice without somebody asking.

#The stream is a snapshot, not an arithmetic series

The first streaming protocol sent the reply as character deltas the client appended. That is an arithmetic series: correct only under exactly-once, in-order, single-stream delivery — and a phone in a building with bad signal offers none of those. A stream the network half-killed kept resolving after the person gave up and resent; its late chunks interleaved with the new turn's, and character-appends from two generations of the same reply hashed the bubble into garbage on screen. The persisted reply was never wrong — the record is always the final parsed object — but the preview lied, which is its own kind of broken.

So the protocol now streams STATE. Each chunk carries the whole reply readable so far, and the client replaces the bubble instead of appending: duplicated, replayed, re-ordered, or interleaved chunks collapse to "newest snapshot wins", a flicker at worst. Three guards close the rest of the class. A per-send turn counter makes a zombie stream's snapshots silent — only the turn the person is actually waiting on may touch the bubble or the flags, and the send guard itself is a ref, because two Enter presses in one tick both read the same stale state once. A 105-second client deadline (over the function's own 90) means a silently killed stream ends like a failed call instead of leaving an immortal bubble — and a per-turn settled flag beside the counter means the stream this turn gave up on cannot revive its own bubble either, which the counter alone let happen: nothing had bumped it, so every late snapshot passed, and the bubble came back from the dead above the failure line. The deadline also aborts the read through a signal, so the loop stops consuming rather than merely being ignored. With the reply as prose, a snapshot is the text of the current round joined, and a new round after a read replaces the last wholesale — the same rule the final reply follows. And the server halts streaming for the turn — logged, final reply unaffected — if the extractor ever fails its own only-grows contract, because showing nothing costs a spinner while showing wrong text costs trust. Snapshots are lightly coalesced (~7/s), which reads as live and costs a few kilobytes a turn.

#One chat surface: the drawer

The individual chat entry points — the ask box on the Tasks screen, the ask box on each shopping list, the escape hatch beside the seasonal question — are removed, and the assistant drawer is the one place a sentence reaches the model. Four surfaces was three too many: each spoke its own dialect of the same seam (one turn of memory here, none there, a thread in the fourth), each needed its own prompt kept honest, and the drawer — which floats over every screen the boxes sat on, holds the household's thread, and can search the web — already did the job the boxes were for. contextualChat, its schema, and planChatActions went with them; useExplain and AskBox are gone.

Not everything moved. The destructive verbs (remove, check off, rename) existed only because the list ask box had the rows it acted on in view, and the drawer does not — they died with the box rather than moving somewhere they would act on a guess, and with them went model-driven check-offs writing purchases; a purchase is recorded by tapping the row, as always. The escape hatch's set_profile and dismiss went too: the seasonal question is back to yes and no, and a question neither button fits is left unanswered.

One verb moved instead, because losing the boxes exposed a hole the drawer was already falling into: asked to add info to an existing task, an adds-only assistant answered with add_task and DUPLICATED the task — the new info landed on a second row beside the one it meant. So the drawer gained annotate_task. The title is matched canonically against the open to-dos in its snapshot, narrowed by the action's list when it names one so a same-titled to-do on the other list stays out of reach, and the note is APPENDED to the details already there — the model saw those details as one line in a snapshot and cannot be trusted to re-send them, so replacing would eat what somebody typed by hand. An add_task carrying a note whose title matches an open to-do is planned as that same annotation, so the duplication cannot come back wearing the other kind; a bare same-titled add still adds, because silently dropping it would make the reply's "added" a lie. A miss still becomes its own to-do carrying the note — wrong shelf beats vanished, unchanged.

#Task details edit like a small note

The details editor was a one-line input that committed on Enter, which was right when a detail was one fact. It stopped being one fact: the assistant appends looked-up facts now, a seasonal suggestion arrives carrying its reason, and a phone number plus an address plus a date is three lines pretending to be one. So the row's editor is a textarea that grows with what it holds, on the note page's contract — Enter breaks a line, the save is the blur, Escape puts the saved text back — and saved breaks survive onto the row (pre-line). Pinning at commit is unchanged, and multi-line details are collapsed to one line in the assistant's snapshot, where each list is one line by design.

One deliberate reversal inside it: the caret opens at the END, where the old input selected everything. Select-all made the first keystroke replace the whole line — a feature when a detail is retyped, a trap once details accrete: three lines of accumulated numbers gone because someone typed a letter.

#The calendar adds by writing the day into the text

The calendar owns nothing — that is its design — so giving it an "add" must not quietly hand it a datastore. Adding a to-do from a selected day writes an ordinary home to-do whose title ends in the day, in the pin's own format: "dentist (Sep 3, 2026)". pinnedDayLabel is exported from the scanner for exactly this, because the writer and the reader must be the same code — a locale-formatted date would land a day off somewhere, and a calendar that misfiles its own additions reads as broken in a way no other screen can. Home, not work: the household's own list is the safe shelf wherever a destination is unstated. Typed relative phrases are pinned first, as the Tasks screen pins them.

"Start a note for this day" seeds an ordinary note with the day label as its body and opens it fresh — caret on the empty first line, because the first line is the title and the title is yours to type. The fresh flag rides navigation state rather than the document, and the focus effect re-fires on the note ARRIVING, not just on its shape: create-then-navigate lands on the editor a beat before the local write echoes into the subscription, and an effect keyed only on blankness ran once against no editor and never again.

Nothing new is persisted, indexed, or subscribed. Everything the capture writes was already reachable by typing the same words by hand, and moving an entry off a day is editing its text, not managing a calendar database.

#Notes split into personal and work, by the to-dos' own playbook

The split reuses every decision the to-dos' split already paid for, because the shape of the problem is identical. A list field ('personal' | 'work') on the document; the total reader defaults a missing value to personal, so every note from before the field existed lands there with no rewrite, no backfill, and no version marker — an offline phone syncing a week later is just more documents the reader defaults (see "Old to-dos become home to-dos in the reader"). The front door is the same card-per-area screen the lists and to-dos have, and each area's screen filters the one shared subscription, so search cannot leak a work note into a personal query.

'personal', not 'home', although the to-dos say home: a home NOTE reads as "notes about the house", and the boiler's model number is that — but "what the vet said" is not, and the split people actually mean is mine-versus-job. The to-dos keep 'home' because errands genuinely are about the household.

One routing wrinkle the to-dos did not have: /notes/:noteId already owns the segment the area routes want. The areas are static routes (/notes/personal, /notes/work) passing the area as a prop — two dynamic patterns on one path are ambiguous, static outranks dynamic, and a Firestore auto-id can never collide with either word. Writers that do not know an area — the calendar's day note, the assistant's add_note — land on personal, the safe-shelf rule everywhere a destination is unstated.

#The title stands apart by one blank line

A title running straight into its body on the next line read as one paragraph, and the one thing the first line is FOR is being read as a name. So noteText now writes a blank line under the title and splitNoteText absorbs exactly one — the separator belongs to the seam, never to the stored body, and the pair stay exact inverses from the stored side, which is the property autosave on blur has always leaned on. Typing the body straight onto line two still commits identically; the note simply reopens with the separator in place. Normalizing at the seam was chosen over a migration for the usual reason: there is nothing to migrate when the reader and writer agree, and an offline phone syncing old text is just more text the seam carries. Exactly one line is absorbed so every blank line past it remains the writer's own — a body may open on a deliberate blank line, and the seam must not eat it.

#A to-do typed in a note is captured, and its line is pinned

People jot "todo: call the vet" in the middle of whatever note is open, and a to-do living only inside prose is a to-do the Tasks screen never shows. So the note editor's commit — the same blur that pins dates — scans body lines for a to-do marker, creates the task through the ordinary addTask writer, and appends (now a to-do) to the line. The pin in the text is the idempotency, by exactly the pinned dates' design: no schema, no rules change, clean merges across offline devices, and the person who disagrees deletes it with the keyboard they are already holding. The recapture that deletion invites costs one extra row one tap from gone, which is this app's cheapest failure.

The note's own area picks the shelf — work notes make work to-dos, personal notes make home ones — because the person filed the note before they typed the line, and that filing is the context the capture rides on. The scan is conservative under the chip scanners' charter, because a false capture is a task nobody asked for on a shared list: the marker must OPEN the line, the two-word "to do" needs its colon so "To do this, unplug the router" stays prose, and the first line never captures — it is the note's name, and a note called "Todo list for the move" is about its to-dos, not one of them.

#A captured line grows a chip to its to-do

The pin says "now a to-do" but not where, and the where is the question a person actually has standing in the note — which list did it land on, and is it still open? So each pinned line earns a chip in the note's facts strip, beside the dates it rides with: the same one-line, sideways-scrolling strip, never a rendered view of the note. The chip deep-links to the task's own list and the row flashes once on arrival — a wash that fades, never a persistent style, by the same posture as "Late exists, but is never marked": arrival is an event, not a state.

The chip is derived, never stored. The note keeps no task id — a field would need a migration, could dangle, and would say nothing the text and the live tasks cannot: the pinned line still holds the exact text the task was created from, so matching is exact title first, canonical equality second. Exact must lead because canonicalize was built for grocery names and strips numbers — "pack box 2" and "pack box 3" are canonical twins, and only the verbatim title tells them apart; canonical stays as the net for casing drift, the planner's own rule. When no open task on the note's own list matches — completed, renamed, deleted — the chip is simply absent rather than dead, the rule every gated control follows: a chip that opens a list where the to-do no longer is would be the app lying about its own writes.

#The to-do points back at its note

The chip made the note point at its task; the row had no idea it came from anywhere. So a captured to-do wears a small faint mark on its title line — the Notes tab's own glyph, saying only "this has a source" — and its facts line carries the way back: a chip reading "from “Moving house”" that opens the note, with Back returning to the list by the calendar's own from convention. The mark is deliberately mute and the chip deliberately speaks: an icon that navigated would be an unlabeled tap target, and a title-line already holding the name, the recurrence and the date has no room for words.

The pairing is derived both ways from the same pinned lines by the same resolution the forward chips and the completion stamp use — one resolveTodo, three consumers, zero chances to disagree. Nothing lands on the task document: a stored back-reference would need a migration, could dangle at a deleted note, and would go stale the moment an edit moved the line — while deriving means a deleted note simply leaves no mark, absent rather than dead, the rule everywhere. An archived note still counts as the source; it is still where the to-do came from, and it still opens.

#A group's id is opaque; its name is display

Supersedes the second paragraph of "Groups are one namespace, and the built-ins are virtual", and the join half of "A group door offers what the other door already has".

A group's document id was groupSlug(name): the folded name WAS the identity. That bought three things — two spellings could not fork, content stayed self-describing because list: "garden" read as words, and no id had to be looked up to be written. It cost one: renaming was not a thing. Items, stores and households could all be renamed; the shelf they sat on could not, and a mistyped folder name was permanent.

Nesting is what made that cost unpayable. A folder you can put inside another folder is a folder people will name badly and want to fix, and "make a new one and move everything" is not an answer when moving is itself the feature being added.

So the id is an auto-id: opaque, immutable, and unrelated to the name. renameGroup writes one field. Every task and note keeps pointing at an id that never moves, which is why this needed no migration at all — and ids written under the old scheme are still folded names, still resolve, and are still renameable. That is history showing through, not a rule; nothing derives an id from a name in either direction any more.

Remapping those old ids to auto-ids was the alternative, and it is the one genuinely dangerous option here. migrateToBrackets is safe because it is content-local: it rewrites prose in place, idempotently, with no identity mapping to keep. An id remap needs the mapping to outlive the run, and it cannot — an offline phone syncing a week later writes list: 'garden' for a document that no longer exists, and nothing re-maps it. The app would survive it (the doors derive cards from content, so the orphan gets a card) but the card would be a ghost sitting beside the real one, and a person would have to move every item by hand. The safety net was built for a deleted group, not for a migration that manufactures orphans.

Three things went with the derivation, and each is worth naming:

The readable fallback. groupName showed the id when a group's document was missing, and it read as words because the id was a folded name. An opaque id would put a key on screen, and a rule of "show it if it looks like words" is the derivation coming back in through the window. So there is one answer for every group with no document: "Untitled group". Two orphans now read alike, where a slug used to tell them apart. That is the price of a name that cannot be recovered from a key.

The id-shape refusals. ., .. and __ are what Firestore refuses as a document id. A name does not become one any more, so rejectGroupRename does not apply them — a group legitimately called "…" is fine. rejectGroupName still refuses reserved NAMES (Personal, Work), which is a different list from the reserved IDS ('home', 'personal', 'work' — 'home' permanently, because documents still store it). One consequence worth stating: "Home" is now an allowed group name, because nothing is called Home and the new group gets an auto-id that cannot collide with the built-in's stored word.

The silent join. Making "Garden" on Tasks when the notes already had one flipped a flag rather than forking, through setDoc(merge) on the shared slug. With mutable names that is not merely invisible, it is unsound: the document born as "Garden" may be called "Allotment" today, and writing to it by name would join the wrong group AND rename it back in one stroke. So addGroup splits into createGroup — always a new document, id returned with the write — and joinGroup, which flips one flag by id and never touches the name. The chips that were a convenience are now the only way, and each carries its group's id.

What none of this fixes: two groups may now share a name. Ids are independent, Firestore has no uniqueness constraint without a shadow index collection, and two phones can rename into the same word regardless. It is refused at the UI, tolerated in the data, and rendered honestly — different ids are genuinely different shelves, and the counts tell them apart. Building the shadow index to prevent it would cost more than the thing it prevents. Merging two groups — repointing every referencing list and deleting the emptied one — is the real answer and is deliberately not built: it would be the app's first cross-document content rewrite, and it deserves its own decision.

#The calendar filters the namespace; it does not join it

Two documents promised the obvious next step — "a surface added later, the calendar being the expected one, is another boolean on the same document" — and the obvious next step is wrong.

tasks and notes answer "which front door shows this group", and a door shows a group because content can LIVE there. The calendar owns no content at all: every entry on it is a note or a to-do that already lives in a group, and the screen persists nothing. So a calendar flag would be answering "may this group appear on the calendar", and the only correct answer is yes, always — a flag set the other way would hide a dated to-do from the one screen whose whole job is saying what is on a day. That is not a preference; it is the calendar lying.

What the calendar needed instead was to stop throwing a group away. CalendarEntry.taskList existed only to build the /tasks/:list route and was null for notes, so a note's group was discarded on the way in. It is now group, never null, on every entry — which is only possible because the built-in shelf has one stored word: a note's 'personal' beside a to-do's 'home' in one field would have been the same old lie in a new place, and this is the field a filter compares.

The filter itself is only, a set of ids the caller builds from groupSubtree, so "show me Renovation" means Renovation and everything filed inside it and the descendant rule stays in one function. It rides the query string as ?g=, beside ?d= and ?m=, because this screen's state has always been its address: a filtered calendar is a link, and Back undoes a filter like it undoes anything else.

Chips, not cards — a card promises a screen and this only narrows the one you are on — and flat, because filtering to a folder already takes what is inside it, so the hierarchy is in what a chip does rather than in how it reads.

The captures on the day panel follow the filter: an add lands in the group being shown rather than always on Personal, which is what made the filter more than a lens. In the folder ITSELF when the filter names one, never in a child: the filter is a view over a subtree and an add needs one shelf, so choosing a child would be the calendar inventing filing nobody asked for.

#A group is managed from the door

Supersedes the removal sentence in "Groups are one namespace, and the built-ins are virtual": "Removal is offered inside an empty custom group, never on the door — deleting in this app always lives where the thing is."

Renaming a group, moving it and removing it were three linkish buttons on the group's own screen. That was wrong twice.

They were furniture. Two of them rendered on every custom group screen, empty or not, standing between the header and the list you came to read — and this is the app that already refuses exactly that for the calendar: the rarer errand is the one that should cost a gesture rather than permanent furniture. That argument was written about per-row buttons, and it lands harder here, because the door is a list of groups: shown per card, on a household with six of them, that is eighteen controls.

And they were in the wrong room. Naming a group, filing it, deleting it — these are things you do while looking at the LIST of groups, where you can see what else is called what and what this one would sit beside. From inside a group you have to go in to rename and come back out to compare.

So all three moved to a held press on the group's card, the same useHold and the same ActionSheet the calendar's entries and the to-do and note rows use. The card is a <Link>, exactly like a calendar entry, so it is the same mechanism rather than a new one. Opening is in the sheet too, because a hold that cannot also do the ordinary thing is a trap for a hold meant as a tap.

On the old rule. "Deleting always lives where the thing is" was defending two real things: that a delete button should not sit on a list where it is easy to mis-tap, and that you should never be unsure which thing you just deleted. A held press answers both without the button. The door's surface carries no destructive control at all; the action costs a deliberate 500ms gesture; and the sheet names the group in its own line before it offers to remove it. What the old rule got right is kept — it is the button on the door that was never acceptable, not the action.

Rename could not become a sheet action. An ActionSheet action returns void, and renaming has to be able to refuse a name and say why, keeping the typed text so the fix is one character rather than a retype. So the action opens GroupRename instead, which renders where the card was — the idiom the shopping rows and the to-do rows already use, so the list does not move under you while you type.

Removal asks card.removable. Both group screens used to recompute it, and had drifted: Tasks counted open to-dos, Notes counted all notes including archived ones, and the Notes gate carried a query === '' term that the Tasks one had no analogue for. taskGroupCards / noteGroupCards already work it out, subtree and archived notes and all. Two local computations went, and the sheet asks the card.

Built-in cards carry no hold. They have no document, so there is nothing to rename, move or remove — and rather than open an empty menu, the press falls through to the platform untouched. That is the same sentence that has always made them undeletable, doing one more job.

#A move is not an edit

Filing a note into another group goes through moveNote, not updateNote, because updateNote stamps updatedAt and updatedBy and a move must not. archiveNote three functions below it already skips them and says why: archiving is not an edit, and bumping the field would reorder a shelf sorted by when things were last written. The same holds here, twice over. Filing a three-year-old note would float it to the top of "recently updated" — and updatedAt is also the anchor calendarEntries resolves an unpinned relative date against, so a move could quietly shift a day the note names.

moveTask is its own writer for a different reason: it is two records in one gesture. The list moves AND the destination is declared on the Tasks door, because otherwise a group would hold content while still being offered as "joinable" on the door it already has content on. That is ensureTaskGroup's job, done at the same moment for the same reason it is done for a to-do captured from a note. moveNote does the mirror.

The gesture is a held press, on the row rather than in it. A row wearing a Move button is a row you read past a button, and filing is rarer than reading a list or ticking something off it. That is the calendar's argument for holding an entry, and rather than restate it the rows use the calendar's actual hook: useHold moved to src/components/ and now serves all four — the calendar's entries, the to-do rows, the note rows, and a group's card on either door. A gesture that behaves differently in two places is a gesture nobody trusts — and the click a touch long-press leaves behind is swallowed in one place, so filing a to-do cannot also open its editor.

One sheet answers "which group?" everywhere it is asked — moving a group, moving a to-do, moving a note — for the reason both doors render one GroupCards: three sheets would drift in wording and in how deeply they indent. Destinations arrive already filtered, so the sheet refuses nothing. What it cannot show, it does not offer, which is the only kind of refusal that never needs a message. Which sheet it is, and why it draws the tree rather than a list, is below: "The move sheet is the filter's sheet".

#The move sheet is the filter's sheet

Two screens ask a household to point at a group, and they were drawing the same two levels two different ways. The filter sheet drew a tree: carets, indented children, a tick on the one in force. The move sheet drew an ActionSheet of flat rows. The move sheet was the one reported as a mess, and both halves of the complaint were the same mistake — a control built out of a list when the thing it lists has a shape.

Flat, a subgroup is a bare word. A Kitchen inside Renovation and a Kitchen inside House are the same row twice, and the sheet whose entire job is filing was the one place in the app that would not say where a group sits. And an ActionSheet is as tall as its actions: a household with a dozen groups got a sheet taller than the phone, with the destinations past the bottom edge unreachable — not awkward, unusable, and worse the more groups somebody made, which is to say worse the more they needed it.

So the rows moved into GroupTree and both sheets render it. The picker is a Popup capped at 78vh with a scrolling body, exactly as the filter has been all along; the fix for the height was to stop having a second answer to a question already answered. The two differ where they should: a filter folds its children away, because it shows a shape; a move opens them, because a destination folded behind a caret is one somebody cannot see to choose.

The group the thing is already on is now OFFERED rather than dropped — ticked, and inert. Removing it was tidier and answered the wrong question: "where can this go" is the second thing somebody wants from a picker and "where is it now" is the first, and a row that quietly is not there answers that one with silence. It is the same reasoning that puts the current shop in the trip picker.

A group's own destinations stay one level deep, and that is not a special case: only a built-in or a top-level group can hold a group, so the tree groupParentChoices produces has nothing to unfold. The component draws what it is given.

#Groups nest; the URL does not

One field does it: GroupDoc.parent, a group id or null. It sits on the ONE document both doors already read, so putting "Kitchen" inside "Renovation" moves it on Tasks and on Notes in the same instant. There is no second parent to forget, which was the whole worry that started this — you cannot move the to-do folder and leave the notes folder behind, because there is no such pair.

A built-in may be a parent. That was only possible after the pair collapsed: 'home' and 'personal' as two words for one shelf meant a child stored under either would sit in a different folder depending on which door you came through — precisely the divergence one shared document exists to prevent. One value, one parent, both doors.

The URL stays flat and id-addressed. A path like /notes/area/renovation/kitchen encodes where a group currently sits, so re-parenting would break every link to it — the same failure immutable ids were chosen to avoid, arriving by another road. Depth is a display fact, read from a document the app already subscribes to, and backName needed no change at all because it resolves through groupName.

The cap and the acyclicity are product rules, and the readers say so. Firestore rules cannot traverse a parent chain, and this app's are membership-only by design, so rejectGroupParent only ever runs on one device with one snapshot — two phones can write a cycle between them however careful it is. Enforcing at the boundary is not available, so the honest alternative is a reader that survives what the boundary cannot stop: a chain that returns to where it started is not a chain, and BOTH ends of a cycle render as roots. Rendering them as children of each other would be correct-looking and would make both groups vanish from the door, which is unfixable from inside the app; a root is visible, and one tap from being put right. Every walk is bounded by the group count, so no data can spin one.

Three smaller things, each stated as what it gives up. A folder's count is its whole subtree, so a folder holding twelve things through its children does not read as empty — at the cost of not being able to tell a folder's own tally from the door. A group on a door brings its parent onto that door, whether or not it is declared there, or a child under a folder the other door owns would be unreachable through a door meant to show everything on it. And there is no order field: siblings sort by name, as customs always have, so there is nothing to keep consistent across offline clients and no reorder gesture to design — at the cost of not being able to pin "Kitchen" above "Bathroom".

MAX_GROUP_DEPTH began at 3 and is now 2 — a root group and a subgroup, and nothing deeper. See "Groups nest once, and the doors show groups only" below for what that ceiling buys and what it costs.

#Groups are one namespace, and the built-ins are virtual

The to-dos said Home and Work; the notes said Personal and Work; and "Notes split into personal and work" already admitted they were one split — mine-versus-job — wearing two names. Now the names unify on Personal and Work, display-level only: tasks keep storing 'home' and notes keep storing 'personal' for the same shelf, because the total readers are the migration everywhere in this app and a rename that rewrites every document buys nothing the readers do not already give. taskListForNote shrinks to the one honest seam — 'personal' becomes 'home', everything else is itself.

Custom groups are documents in groups/{slug}, and the slug IS the folded name. The name as identity means two spellings cannot fork into two groups, renames are simply not a thing, and — decisive — content is self-describing: a task's list: "garden" stays readable words even if the group document is deleted out from under it, so the doors derive cards from content as well as documents and nothing reachable can go dark. The built-ins get no documents at all: layer 1 supplies them, which makes them undeletable by construction rather than by a guard someone could forget.

One document serves both tabs, but a group exists only where it is declared: tasks and notes booleans, so a group made on the Notes door does not appear on the Tasks door until something real crosses — today, the first to-do captured from one of its notes flips tasks on. Making a same-named group on the other door joins the document rather than forking or refusing. The flags are how the calendar (or any later surface) joins the namespace: one more boolean, no reshaping. Removal is offered inside an empty custom group, never on the door — deleting in this app always lives where the thing is — and archived notes hold their group open, because removing it would strand the recovery shelf. Both doors render one shared GroupCards component: the unification made structural, so they cannot drift apart again.

#The row's editor is the note page, all the way

"Task details edit like a small note" stopped halfway: the editor had the note's contract (blur saves, Enter breaks a line) but not its shape — the title lived outside the surface, so renaming meant a different gesture from annotating, and the two editors people use most were almost-but-not-quite the same thing. Now the row opens as the note page in miniature, through the same layer-1 seam: the first line is the title, one blank line stands it apart, the details follow. One pattern to learn, one seam to test, and the editors cannot drift because they share the code that defines them.

The task kept two edges the note deliberately lacks. A blanked first line leaves the name alone — "Untitled" is a fine note, but the row IS its title, and deleting everything reads as "clear the details". And a detail-less task seeds as "title\n\n", the separator already typed, so the end-of-text caret lands ready for the detail rather than mid-name — tap, type, done, exactly the flow the old details-only editor had.

The chip strip moved into the edit state with it, reversing the old rule (the strip used to describe the saved detail and hide while editing). The chips are the editor's tools — call the number you are looking at, jump to the source note — and they read live from the draft, the note page's own arrangement beside its textarea. What the resting row keeps is a hint: a small "i" wearing the chips' own pill shape, shown when opening the editor would offer any chip, hand-rolled beside .row-tag rather than an icon font's circle so it echoes the shape of the thing it promises. The list stays scannable; the row tells you when it is worth opening; supersedes the mark and chip placement in "The to-do points back at its note".

Superseded in part by "The row's chips live on the row, not only inside its editor": the strip and the chips stayed, the edit-state exclusivity and the "i" mark did not.

#Completion writes itself back into the note

A note line reading "(now a to-do)" for a to-do finished last spring is the text lying, and the note is the record that outlives the row — the task document is destroyed by the very completion the note would want to remember. So the row's box does both: completeTaskStampingNotes completes the task and rewrites the pin on every line that captured it, "(now a to-do)" becoming "(done Sep 3, 2026, 2:41 PM)" — the date AND the clock time, because "when did that actually get done" is the question a note answers years later, and locale-free like every pin so the stamp reads identically on every device that syncs the text.

Which lines get stamped is the chips' own resolution (resolveTodo, shared so they can never disagree), against the open set as it stood at the tap: the stamp lands exactly where the chip pointed a moment before, and a canonical twin's line — "pack box 3" beside a completed "pack box 2" — is left alone while its own task lives. The stamp is terminal: capture skips any "(done …)" parenthetical, loosely by shape rather than verbatim, because a resurrected task is a to-do nobody asked for twice while a missed capture only costs retyping a line. Deleting the stamp re-arms capture deliberately, the same keyboard-in-hand contract every pin honors.

Recurring tasks stamp nothing — completing one re-arms it, so its lines still name a live to-do. And the stamp can lose to a household member holding that note open in the uncontrolled editor, whose next blur commits their older text over it; that is the editor's standing merge posture, and the cost is a pin reverting to "(now a to-do)" with its chip gone — stale words, no data lost.

#The drawer sees the note on screen, and no other body

The context snapshot has always carried note TITLES only: twenty bodies of prose would drown the lists the assistant actually acts on, and the server truncates the whole context at 4000 chars regardless. But the drawer floats over the note editor too, where "summarize this note" and "make to-dos out of this" are the natural asks and titles alone cannot answer them. So the FAB moved inside the router (AssistantFab): when the route is an open note, that note's text — capped well under the server's truncation — joins the snapshot, and the system prompt says "this note" means that one. Anywhere else, nothing changes and no body is sent. The route is the signal because it is what "the note on screen" already means; the drawer learns no new schema, and a note nobody is reading reaches the model exactly as often as before: never.

#The row's chips live on the row, not only inside its editor

Chips drawn only in the edit state are chips that can be reached only by a gesture that can destroy them. Pressing one blurs the textarea; the blur commits; the commit closes the edit state; the strip — computed from the draft, and only while there was a draft — leaves the DOM, and the click that was going to navigate lands on nothing. A desktop pointer can be talked out of this (prevent the mousedown's default, keep the focus, commit from the strip's own click, all of which shipped), but a touch device gets to decide for itself when the keyboard goes away and the blur fires, and on one that blurs before it presses, the fix is not in the ordering — no ordering saves a control that has already been unmounted. Two rounds of "the chips still are not tappable" is the design saying so.

So the strip never unmounts. Editing, it reads the draft, exactly as before — the editor's live tools, dates resolving against now. At rest it reads the saved text, anchored at the task's own write time, which is the same set of chips computed from what is actually stored. The tap that matters — "open the note this to-do came from", "call the number in the detail line" — no longer needs the editor at all, and mid-edit the strip is still there under the finger because it was there before the finger arrived.

The "i" mark goes with the change. It existed to promise chips that were hidden; the chips are on the row now, and a pill saying "there is something inside" beside the thing itself is noise. The cost is the one the mark was buying: a row with a fact, a date or a source note is two lines instead of one. That is the right trade — the list stays scannable enough, and the alternative was a control nobody could use. The strip keeps its ceiling (two facts, two dates) so a row cannot grow a paragraph of chips.

#A completion stamp is a record, so nothing chips it

Completing a to-do writes "(done Sep 3, 2026, 2:41 PM)" into the note line that captured it, and the date scanner — which reads every note for the days its prose names — dutifully found the date inside and made a chip of it. The note grew a chip to the calendar for the day the to-do was finished, and the calendar grew an entry to match: the app's own bookkeeping, rendered back as if someone had planned it.

Both surfaces are forward-looking. A chip says "here is a day worth going to"; the calendar says "here is what lands on this day". A completion is neither — it is a fact about the past, kept because "when did that actually get done" is a question a note answers years later. So the scanner refuses the stamp's whole span: not the date inside it re-labelled, not the chip made quieter, refused. The stamp stays exactly as written in the text, where it was always the point.

The shape got its own module (notes/completionStamp.ts) rather than living in either scanner, because two of them now depend on it — the to-do capture refuses to re-capture a stamped line, the date scanner refuses to chip it — and neither may import the other (the capture already reads the date module's pin format). Copying the pattern into both would have been the smaller diff and the pair that drifts. The match stays loose, any "(done …)" parenthetical, inheriting the capture's own bargain: someone who writes "(done at last, Aug 28)" by hand loses a chip, which costs a glance at the calendar, while the alternative is the app arguing with a line the person wrote themselves.

#A note the assistant makes goes where they asked

"Make a work note about the printer" made a note in Personal. Not a matching bug: add_note had no destination at all. The action schema's list was documented "Null for add_note", the planner never read one, and the writer's own default — personal, the shelf that catches the calendar's day note and every note written before the areas existed — quietly caught every note the assistant made. Every part of that chain was behaving as written, which is how a wrong shelf survives a code review.

So add_note carries its area, and the enum grew the notes' spelling of the shared shelf: personal beside home. Both are accepted for a note and both mean Personal — the model is taught the pairing for the to-dos, and being strict about which of the two words it uses for a note would fail the ask while looking like a schema violation. Anything else, missing included, still lands on personal: a wrong shelf is one drag from fixed and a dropped note is gone, the same bargain every other default in the planner makes.

#The assistant may rewrite the note in front of you, and nothing else

Every destructive verb is kept out of the drawer for one reason: it floats over screens it cannot see, and a remove on a guessed row is the mistake that loses data. The note on screen is the one place where none of that holds. The model was handed that note's actual text, the person is looking at the very surface the write lands on, and the change is visible the instant it arrives. "Fix the typos", "turn this into a list", "add a section for the parts" are the natural asks of a note you are reading, and answering them with "I can't, here is the text — paste it yourself" was the app being precious about a rule whose reason had run out.

So edit_note exists, fenced by what made the rule right in the first place:

  • Only the open note. The schema has no field naming a target; the planner supplies the id from the route. A model that invents a note has nowhere to put it, and a turn that outlives the page it was asked on plans nothing at all.
  • Only a note it saw whole. The snapshot caps a note's text, and a model asked for the new text can only return what it was shown — so rewriting a note it saw the head of would silently delete the tail. One constant is both the cut and the guard, so the two cannot drift.
  • Never to nothing. The blank-name guard every action already passes is what stops an edit from emptying a note.
  • One tap back. The screen keeps what was replaced and offers it as a chip. Text that changes under your eyes must be text you can get returned, and the same offer covers a household member's save arriving mid-read.

The last of those is the general fix the feature paid for. An uncontrolled editor never noticed the document changing under it, so a note saved on another phone was invisible until you left and came back — and your next blur committed the stale text over theirs. Now an arriving version is taken whenever the textarea has nothing unsaved in it, and ignored when it does: the person holding the phone outranks anything that arrives, which is the same standing merge posture as before, just no longer blind.

#A group door offers what the other door already has

One document serves both surfaces, so making "School run" on Notes when the to-dos already have one joins it. That was true from the day the namespace was unified — and completely invisible, because joining happens through groupSlug(name) and nothing on the screen ever told you what the other door called its groups. Type "School Runs" and you get a second group that can never rejoin the first: the slug is the identity, and renames are not a thing here by deliberate design.

So the door shows them: chips under the New group box, led by "Already on Tasks:", each one tap from a join. They narrow as you type, folded exactly as search folds, so the group you are about to duplicate surfaces on the first few letters of it. Chips rather than cards, because they are not places you can go yet — a card promises a screen, and there is nothing there until the join happens.

Documents only, never content: group creation always writes a document, and the capture that carries a group across surfaces flips a flag on that same document, so a name offered here is always a name that can be joined. And the offer is one-directional per door, which is the whole point — a group already on this door is a card above, where it has been all along.

#A photo shows itself, rather than being counted

The note page said "3 photos" and made you open a viewer to find out which three. But a photo in a note is nearly always a thing you took to look at later — the boiler's data plate, the receipt, the whiteboard before it was wiped — and "which one is the parts list" is exactly the question a count cannot answer. So the chip became a row of thumbnails, each opening the viewer at its own photo.

It costs a row: the writing surface subtracts it, as it already subtracted the facts strip. That arithmetic moved into a single --editor-taken variable rather than a third whole height calculation, so facts and photos can appear in either order or together without the calculation forking again. The row is fixed-height and scrolls sideways, the same bargain the strip made — a wrapped second row would be paid for out of the note you are writing.

#The app opens from its own disk, and says when that is all it has

Everything the app shows was already in IndexedDB, and it still would not open. Both boot reads — the user document, and the household id it names — went to the server through getDoc, and every subscription in the app waits behind them. getDoc does fall back to the cache, but only once the SDK has concluded it cannot reach the server: fast when a request is refused outright, and slow when requests hang instead, which is what a captive portal, a filtering proxy and a half-open mobile connection all look like. Slow here means the boot's own twelve-second deadline, then a retry, then another — "Setting up your household…" over a phone that had every answer on it.

So both reads go to disk first. It has to be both: ensureUserDoc awaits in front of ensureHousehold, and fixing only the second leaves the first holding the door shut — which is exactly what the first attempt at this did, and what the new end-to-end test caught. What makes it safe is that neither value can go stale: a household id is assigned once at first sign-in, the back-pointer is only ever appended to, and this build has no flow that removes one. The server read still runs, awaited by nothing, so the watchdog hears an answer and the create path is unchanged for anyone who has no household yet.

That immediately broke the honesty the watchdog was built for. It measures silence — an ask with no answer — and a cache-served snapshot is not an answer: with persistence it arrives at once, and again on every local change, precisely while the path is shut. Counting those would have taken the "can't sync" strip away at the exact moment it matters most, now that the app has data on screen rather than an empty list: everything looks fine, and nothing you write is leaving the phone. So noteSnapshotFrom asks the snapshot where it came from, and only the server clears the clock.

The cache is also unbounded now (CACHE_SIZE_UNLIMITED). A household's lists, to-dos and notes are a few megabytes of text at the outside; the only thing the default 40MB LRU could ever evict is the offline copy of something about to be wanted in a shop with no signal.

Testing this needed the one profile the emulator deliberately does not use. Emulator runs take the memory cache so a restart means what it looks like — IndexedDB otherwise outlives the emulator and resurrects deleted documents — so tests/e2e/offline-boot.mjs sets VITE_EMULATOR_PERSISTENCE=true and runs its own dev server. It also checks, from inside the browser, that a Firestore IndexedDB database actually exists before believing anything else it sees: during development this test spent an afternoon silently talking to a different dev server that Vite had shifted onto its port, and a test that can be lied to about its own subject is worse than no test.

#Moving a dated thing is editing the text that dated it

The calendar has never had data of its own: a day carries an entry because a note or a to-do says so in its own words. A snooze button that wrote a snoozedUntil field would have ended that in one commit — two places where a day lives, a reader that has to prefer one, and an entry whose text says Tuesday sitting on Thursday.

So a move rewrites the phrase. "dentist Sep 3, 2026" becomes "dentist Sep 4, 2026", in the note or the to-do that always held it, and the calendar shows what it always showed: what the text says. A pinned phrase goes whole — "call in 3 days (Sep 3, 2026)" becomes "call Sep 4, 2026" — because the pin is the value and the relative words were only the input that produced it; leaving them beside a day three days further off would be the text lying about itself. ISO stays ISO: someone who writes 2026-09-03 is telling you how they write days.

The exception is a recurring task's slot, which is genuinely a field, and it moves as a field. Only the PENDING slot: the rule is untouched, every later slot is still the rule's, and completion re-arms from the rule — so a snooze says something about this occurrence and never about the schedule. That is what makes it safe to move a derived value by hand, and it is the only place in the app where one is.

"The next day" is measured from where the thing stands, not from where it was written: one day on for something still ahead, tomorrow for something whose day has already gone. Pushing a forgotten thing one day deeper into last month is the app pretending to help. Late still exists and is still never marked — this is a person saying "not yet", which is the opposite of the app saying "you're behind".

#The move is held, not shown

Every entry could wear a "⋯". Then every row on the screen is read past a button, on the surface whose whole job is telling you what is on a day. Moving something is the rarer errand, and the rarer errand is the one that should cost a gesture rather than permanent furniture — so it is a long press, with a right-click for the desktop half and one muted line under the entries so the gesture is findable at all. Progressive disclosure means the screen stays what it is until you ask it for more.

The sheet names the destination — "Move to Sat, Sep 5" — rather than saying "snooze" or "push". A person moving something wants to know where it went, and "the next day" means different days for something due tomorrow and something that was due last month. Open sits beside it, because a hold that was meant as a tap must not be a dead end.

Long press has no browser event, so it is a timer the finger can outrun, plus contextmenu for the platforms that do have the gesture — a desktop right-click, and Android's own long-press on a link, whose native menu would otherwise open over ours. iOS raises no such event, hence the timer, and the callout is turned off in CSS. Only the touch press swallows the click it leaves behind: a right-click produces none, and a flag set there would sit waiting to eat somebody's next ordinary tap.

#The thread in the drawer is yours

The drawer's thread was household-shared from the day it existed: "what did we decide about the filters?" was the question it was built to answer, and a per-device history would have shown each person half a conversation. That reasoning held right up until the thread met a question it could not survive: in a shared list app with a shared thread, you cannot ask about a surprise present, because the person it is for reads the transcript. A chat surface someone has to self-censor is not a working surface at all — and the chilling is invisible, because the questions it costs are exactly the ones never asked.

So the thread is per-member now: households/{hid}/assistantThreads/{uid}/messages, readable and writable by its owner alone. The sharing moves to where it always actually lived — the outcomes. Every action a turn lands (the item, the to-do, the note, the annotation) goes to the household's collections exactly as before, in everyone's sight; only the conversation that produced it is private. The household shares results, not drafts. Per-member, note, not per-device: one person's phones still converge on one thread, which is the half of the old argument that survives.

What is deliberately given up: "you two talked about filters yesterday" is no longer answerable — by anyone, including the assistant, whose prompt now says other members have threads it cannot see rather than implying a memory it does not have. What is deliberately kept: the thread stays under the household, because the context it discusses is the household's, and leaving the household leaves the thread behind. The rules shape is the part worth remembering: Firestore rules are OR'd across matching statements, so the recursive member rule cannot be narrowed from below — assistantThreads is excluded in the catch-all's own condition and re-granted owner-only, and {rest=**} under the owner means whatever a thread grows later (attachments) inherits the same privacy without touching the rules again.

The old chatMessages collection is left in place, member-gated, unread: its interleaved voices belong to nobody in particular, so migrating them into someone's private thread would be wrong twice over — and a phone still running the previous build keeps working against it instead of watching its chat listener die with a permission error, which the health watchdog would faithfully report as "can't sync" on a phone that can sync fine. Invisible garbage, tolerated, exactly like an orphaned photo; sweepable later once no old builds remain.

Supersedes the shared-thread half of "A thread in the drawer, one turn in the ask boxes". The referent window, the device clock, adds-only, and Sonnet-for-latency all stand.

#A day is a mention until you say otherwise

(This supersedes the entry below, which solved one case of the same problem with a marker of its own. The brackets subsume it: once a bare date files nothing, there is nothing left for a "do not scan this document" stamp to do.)

Filing every day a text named was the original bet, and it was wrong in the common case. Most days a note mentions are not appointments — "we moved in Sep 2019", "the crate lands Sep 12 so clear the bench", "it happens tomorrow so get ready". Those are context. Filing them put documents on squares nobody chose, and the person who wanted the day recorded and the person who merely wrote it down had no way to tell the app which they were.

So a day counts when it is in brackets: [Sep 12, 2026]. Everything else is a mention, and a mention still gets its chip, because "which day is that?" is worth answering even when the note does not belong on it. The brackets are typed by hand or written by the app; there is nothing else to learn.

The app offers rather than deciding. When a save introduces a day the document has not committed to, a bar appears at the top of the screen naming it, with one action, and expires if ignored. Nothing remembers that it was ignored: declining is the common answer, and an offer that came back would be a nag — the rule that governs every other unprompted thing here. It sits clear of the screen header, because a bar over Back and the title takes away the way out of the screen at the exact moment somebody has decided they are done typing.

Old documents were rewritten rather than grandfathered. A cutoff was the first answer — no rewrite, no rules change, createdAt never moves — and it was wrong: it leaves two behaviours in the app for good, an old note filing a day nobody meant with no way to say so, and every explanation of the feature carrying an asterisk. Every day those documents filed was already a commitment in everything but syntax, so the honest move is to write the syntax down.

migrateToBrackets is therefore this app's first real data migration, and the reason it needs one is worth naming: everywhere else a schema change is absorbed by a total reader's default, and the reader IS the migration. That answer cannot reach here, because these days live in prose and prose is exactly what had to change.

It runs from the client, since a household app has no operator to run a script, and it earns that by being idempotent (a bracketed day is skipped, so a second device writes nothing), resumable (the household is stamped only after every write lands, so a run that dies halfway finishes next time and writes nothing twice) and faithful (each document is pinned against its own anchor BEFORE it is bracketed — the write moves a note's updatedAt, which is the anchor an unpinned "tomorrow" resolves against, so bracketing without pinning would have changed the day in the same breath as recording it). Households created since are born stamped and never run it.

The offer's countdown is the undo bar's, extracted rather than rewritten. "An undo that cannot be seen is not an undo" was learned once, expensively, and a second transient bar with its own slightly different timer would have learned it again.

#The calendar already knows the day, so it stops reading

Adding "This thing happens tomorrow so get ready" from the 4th's square put it on the 4th and the 5th, and rewrote the title into "This thing happens tomorrow (Sep 5, 2026) so get ready (Sep 4, 2026)" on the way. Both halves were each feature working exactly as designed: the calendar stamps the day it adds for, and the scanner finds every day the text names. Together they file one intention under two days.

The mistake was scanning at all. Everywhere else in the app a date is found, because nobody said which day was meant — that is the scanner's whole charter. The calendar's add is the one place where somebody did say: they tapped a square. Reading the words after that cannot add information, only a second day, and the second day is always the wrong one — you write "it's tomorrow" on the day you need to get ready, not on the day of the thing.

So a document the calendar creates carries (for Sep 4, 2026), and a document carrying that is not scanned: detectDates returns the stamped day alone. One return covers the chip strips, the calendar's entries and every later reader, which is why the rule sits in the scanner rather than in calendarEntries — a document-level filter there would have fixed the calendar and left the note still wearing a chip to a day it does not appear on.

It is a stamp in the text, not a field, for the reasons pinned dates already are: no migration, no rules change, clean merges across offline devices, and a person who disagrees edits it with the keyboard they are holding. It follows the pin's shape — parenthetical, one line, no nesting, locale-free — and the pin's validation, semantic rather than syntactic, so "(for later)" is an aside and not a broken stamp. The first stamp wins: a document has one home day, and a second is somebody's prose.

What this gives up, deliberately: a date typed into a stamped document later — "renew permit Oct 30" — gets no chip and no entry. That is the honest reading of "the day is settled", and the escape is the one every marker here has, deleting it, which re-arms ordinary scanning exactly as deleting a to-do pin re-arms capture. The alternative — stamp plus ordinary scanning — fixes nothing, because "tomorrow" is in the text from the very first save.

Existing calendar-added items carry a bare (Sep 4, 2026) and keep the old behaviour. No migration: they are one edit from the new one, and until then they are what they always were.

#What earns a toast

Two bars grew independently — the undo bar and the calendar offer — with the same card written twice, the same entry keyframe written twice, and the reduced-motion guard on only one of them. The countdown was already shared, because getting it wrong once was expensive; the rest of the bar had no better claim to being written twice. So there is one Toast, and the interesting question became which of the app's inline notices should use it.

A bar earns the viewport when it reports an EVENT, carries at most one answer to it, and is safe to miss. Everything in that sentence excludes something:

  • Progress is not an event. "Adding photo…" has no end it can promise, and a bar that must not expire is the wrong shape. It stays beside the thumbnails it is about.
  • A live condition is not an event. The connection strip describes a state that is still true; auto-expiry would hide it while it holds.
  • An undo for something you did not do is not safe to miss. "Just changed · Undo" is the only way back from the assistant or another phone rewriting your note. It deliberately has no timer, and clears on your next keystroke instead — an expiring undo for text you never wrote is precisely the failure "an undo that cannot be seen is not an undo" was written about. Narrowed — see "An undo for another person's edit is an overwrite" below: the chip is for your own account's writes, never another member's.
  • A message about a field belongs under that field. The group door's refusal and the pantry read's advice each sit under the control that caused them, where the eye already is.
  • A question is not a notice. The store picker and the seasonal question are inline and unhurried by three separate decisions; neither is transient at all.

What moved: the note photo failure, which made you tap to dismiss a failure — housekeeping for something that already went wrong — and the shops screen's "Added Tesco.", a receipt that never left until the next save replaced it.

The assistant's failure line moved too, but not to a bar. It was a chat bubble on the assistant's side, which put words in the assistant's mouth about a call that never reached it; it is a centred note against the thread now. It also outlived its moment, surviving until the START of the next send, so a drawer reopened an hour later still carried it. It clears on a turn that succeeds and on the connection watchdog returning to ok — a proxy, since that watches Firestore rather than the callable, but a safe one: if the assistant is still down, the next attempt raises it again. Clearing early costs a line nobody needed; keeping it costs a lie.

The exit runs on a timer rather than animationend, and that is not a style preference. prefers-reduced-motion sets the animation to none, so animationend never fires and the bar would sit on screen forever — for exactly the people who asked for less motion. Verified both ways in a browser, including by putting animationend back and watching the bar refuse to leave.

#Groups nest once, and the doors show groups only

The hierarchy stops at two levels: a group at the top of a door, and a group inside one. Root group → subgroup, and no further — enforced in the data model rather than in any screen. rejectGroupParent refuses to write a third level, and groupParent refuses to RENDER one: a parent that is itself a subgroup, like a dangling or cyclic parent, reads as null, so however a document is mangled every group stays reachable at depth 0 or 1.

That ceiling is what makes the machinery unlimited depth drags in not merely avoided but inexpressible: breadcrumbs, tree pickers, recursive move validation. None of them can be needed by a shape the model cannot hold. The move sheet indents once and stops. groupSubtree is a group and its direct children. Every reject in tree.ts is a flat check. The ids stay one flat namespace too — a subgroup's id is still just its id — so routes, list values and cross-door joins never learn where a group sits.

The doors show groups and nothing else. Personal and Work always, then the top-level customs by name — a clean list of doors, with every note and to-do behind one. An earlier cut of this change went the other way: it dissolved the Personal card into the tab itself, so the root mixed top-level group cards with the ungrouped notes below them. On a real phone that read as a junk drawer with folders parked above it, and the household's own words for it were the reversal. The catch-all keeps its card; "where are my personal notes" has a one-word answer again.

Counts are rollups. A folder shows its own content plus everything filed under it, because direct-only counts look broken the moment a group has children: Work showing 15 beside subgroups holding 11 and 10 reads as a bug, not a convention. Work shows 36. A group's rows render only on its own screen, so nothing is counted twice anywhere.

Nothing folds, because nothing nests on screen. The disclosure caret, the per-device fold memory, the "2 groups" tally, the indent classes and the card shell that carried them are all gone. A card is a door: what is behind it renders behind it, on its own screen, one tap in. A group's screen shows its subgroup cards over its own content through the same GroupCards the doors use, so the levels cannot drift apart — and each screen mounts under its own key, so an open New group box cannot ride from one group into the next.

What this gives up is real: you cannot see a folder's children without opening it, and you cannot tell a folder's own tally from its subtree's. Both were bought with a tree on a 390px screen — an indent per level, a gutter on every card whether or not it folds, a fold state per device per door, and a count that had to mean two different things at once to explain itself. A flat door of plain cards is worth more than either.

CaptureInput is the app's front door for typing something new, and on the screens that are LISTS OF THINGS it was the wrong weight. A group door showed a full-width box and a solid Add button under its cards permanently, for a gesture — making a group — that a household performs a handful of times in the life of the app; the calendar showed the same box under every day you tapped, including the ones you opened only to read. The loudest control on each screen belonged to its rarest act, and it sat where the eye lands after the list.

So the box goes behind a .linkish — the app's quiet inline action, the voice the calendar's "Start a note for this day" already spoke in. At rest the door is cards and the day panel is what is on the day. Both group doors get it from one component, GroupCards, and the calendar's day from the same CaptureLink; three surfaces, one behaviour, by construction.

The link stays put when the box opens, above it, and says Cancel while it is open. Two things fall out of that. A phone has no Escape key, so a box that could only be dismissed by leaving the screen would be a worse resting state than the one this replaces — the toggle is the way back that works everywhere, and Escape is kept for the desk. And the words on a control should say what tapping it does: once the box is open, "New group" is what the box does, not what the link does, and leaving the original words would print them twice in a column, since the box wears the same ones as its placeholder.

Nothing closes it by itself. Closing on blur is the obvious extra and it is exactly the thing that breaks the door: the join chips sit INSIDE the open box, and a tap on one blurs the input before its own click lands, so the box would close and take the chip out from under the finger. A guard against that is possible — track a pointer that went down inside, or test relatedTarget — and every version of it is a heuristic that fails differently on touch. One gesture opens it and the same one shuts it.

What the box brought with it goes behind the link too. The refusal line is about a name that only exists while the box is open, and the join chips answer the other half of the question the box asks — "is this group already somewhere?" — which is only asked while making one. A refusal outliving its box would be a complaint about words nobody can see; onClose clears it.

A closed box forgets what was half-typed in it, because CaptureInput owns its own value and is unmounted. That is the honest behaviour for a control that is put away rather than minimised: the link is not a drawer.

People join the app the way dates and to-dos did: derived from the prose, never joined to it. There is no link table, no assignment step and no stored mentions array, because every one of those is a thing that can go stale the moment someone edits a sentence. Delete @sam from the line and Sam is no longer named there — the mention WAS the link. The precedent is the calendar, which reads live documents and keeps no dates array in sync; detectPeople takes the people list as an argument the same way and stays pure.

The handle fold — how @sam-chen and a person named "Sam Chen" come to mean one word — is withheld from src/lib/index.ts, exactly as groupSlug is and for the same reason: exported, it would invite a consumer to derive identity from a name, which is the scheme opaque ids exist to prevent. What the barrel exports are the operations built on the fold — detection, the name a handle births, the token the menu types, the rename and merge arithmetic — so the equivalence relation has one home. A rename pushes the old name's fold onto aliases and every note keeps resolving; a merge folds one person's handles into the other's; in both cases no note is touched, which is the property the whole design buys.

The scanner never guesses at a bare capitalised word — "Kitchen" is a group in this tree and a name in somebody's household, and capitalisation is the signal detectDates already refuses for weekday names. And the fact shapes claim first, so sam@work.com stays an email rather than becoming a person called Work: tuned to miss before it lies, the charter every chip scanner already serves.

#The survivor is a choice, defaulting to where you stand

Merging folded everyone picked into the person whose page you were on, and said so in a sentence over the button. The report was that it "always happens backwards" — which is what a positional rule feels like from outside, because the page you have open is not evidence about which spelling the household keeps. You reach a person by tapping a mention, and the mention you tapped is the one that was written wrong.

So the card names the survivor and lets it be somebody else. The default does not move — you still stay where you stand, and a merge nobody thinks about behaves exactly as it did — but the direction is now stated twice before the write: in the consequence sentence, which reads in whichever direction was chosen, and on the button, which names the survivor instead of saying "in". There is no swap control, because with more than two people picked there is no single thing to swap; there is a row of the names involved and you press the one to keep.

Keeping somebody else's name moves the route to their page. The alternative is staying on a document that the write just deleted, which renders "That person no longer exists." over a merge that worked perfectly — the app reporting a bug it does not have. replace, not a push: the page behind is gone, so a Back that returned to it would be a dead end.

The write is unchanged, and that is the point of the shape: mergePeople always took the survivor as an argument, and the screen was the only thing that had decided it could only ever be one of them.

#A person is born at save, whole

@sam typed into a note creates a person called Sam on the same commit pass that pins dates and captures to-dos — and that is a complete answer, not a stub awaiting fields. There is no half-populated person, no "finish setting up Sam" state, and nothing waits on a round trip while anyone is typing: the scanner runs at blur, exactly where the to-dos are captured, so an abandoned draft leaves no people behind. The document exists for two reasons only — a display name that may hold a space, and the aliases that make renames free — so a name derived from the handle IS the whole document.

Renaming and merging never rewrite a note. The old name's fold joins aliases and every @sam in three years of prose keeps resolving; two people who turn out to be one merge by folding handles, and again no text is touched. This is the property groups bought with an opaque id, bought differently here because an id written into prose would be a key on screen.

The chip beside the page renders the display name over the handle — a name over a stored id, as groupName does — and the prose keeps saying @sam. The note is never rendered as a view (DECISIONS, "Detected facts become chips beside the page, not a rendered view"); the chip strip is where the name lives. The accepted cost, stated in the intent doc and accepted here: search folds over the text, and the text says @sam, so searching "Chen" will not find that note. The person page is the better answer to that question; if it starts to bite, one function expanding a matched name into its handles can join noteMatches later, changing nothing else.

#The @ menu pre-selects; it never applies

Ambiguity waits for the second Sam. The first @sam a household types asks nobody to qualify anything — the handle resolves or births, and that is the whole flow. When a second Sam appears, the menu's job is to present the Sam who exists as an answer to correct, never to apply him on its own: the existing person leads the row pre-selected, and the trailing "someone else…" does nothing but close the menu, because the distinguishing act is typing more — @samk, @sam-b, whatever would be said out loud. This is inferStore's nearest rule wearing the menu's clothes, for inferStore's reason: a silently applied wrong answer poisons everything attributed through it, while a pre-selected one costs a glance when right and a few keystrokes when wrong. Two Sams in a household are two shops in one shopping centre.

The menu itself is an accelerant, never a dependency. Typing the whole handle and ignoring it always works — offline, against an empty list — which is what makes first run a non-problem rather than a state to design: nobody exists, so no menu appears. It orders by most recent mention because a household holds tens of people and the one named yesterday is the one being named now. And picking a row splices a TYPED token into the text, resolved back through the fold at save exactly as a hand-typed handle is: the menu writes words, never ids, so capture keeps its one path.

#A person is not a group, and More is not a screen

The tempting shortcut was folders: 7Boss and 7MD under Work are people wearing a folder's clothes. The cost is the thing the feature exists for — a person cuts ACROSS Personal and Work, while the tree gives every document exactly one shelf and one parent. People are the axis that crosses; the tree stays what it is. So /people/{id} is a derivation (personAppearances, calendarEntries' pattern): every note and task whose text names the person, grouped by the group each lives in, read live on every render with nothing stored that could go stale. Neither /people nor a person's page is somewhere anyone goes first — a person is met by tapping their name where it was written — and the index has no New button, because a person is made by naming them in prose and a directory that could mint people would be a second capture path.

The fifth tab slot becomes More: one ActionSheet launching People and Settings (Settings apart under a divider — content above, the app below), with the destinations in one array both the sheet and the highlight read, so the slot lights exactly when the current route is one of its own. Counts belong INSIDE the sheet — asked for by the tap that opened it — and never on the tab, which is the "a stated count is information; a badge would be pressure" rule holding its line.

One stated trade: the intent asked Back to close the sheet before leaving the screen AND for the sheet to push no history. On this router both are not simultaneously possible (swallowing Back requires a pushed entry), and no history is the dearer property — a menu that was only a springboard must not become a step Back has to climb — so the sheet closes on any navigation instead, the hardware Back included.

#Bare ask needs its colon; ask-at does not

The ask marker joins the to-do capture's grammar with the to-dos' own asymmetry, worn one notch differently. ask @sam: when does the tile order land? captures with or without the colon, because the @ is already an unambiguous signal that this line is a marker and not a sentence — nobody opens prose with ask @. Bare ask gets no such benefit of the doubt: "Ask the plumber about the boiler tomorrow" opens too many honest sentences, so a question addressed to nobody requires ask: with its colon, exactly as the two-word to do requires its own. Tuned to miss before it lies — a missed question costs retyping a line, a false one is a question nobody asked, sitting on three surfaces.

A question addressed to nobody is a real state, not a degenerate one: it is a question about the boiler, not for a person, and every surface renders it identically minus the person chip. And a question is deliberately nearer a note than a to-do: a task is destroyed by completing it, while a question is completed by being answered and then KEPT — the answer is a fact the household now holds. These are also not the profile's questions (seasonal/profile interviews the household about purchases, on /settings/knows); they share a word and nothing else, which the module docs on both sides now say out loud.

#The answered stamp writes into somebody's prose, and that is the point

Answering a question rewrites its pinned line: (now a question) becomes (answered Sep 3, 2026: two years from install), in the note where the question was asked. The alternative — the answer living only on the question document — is cleaner and loses the thing the feature is for. Notes search is substring over the note's own text, so an answer that is not in the text is an answer nobody can find by searching, and a note that says only "ask @sam: when does the tile order land?" three months later is a note missing its own ending. The stamp is what makes the note self-contained.

The cost is real and stated: a line grows under the person who wrote it. So the stamp is bounded — a short answer rides the line, and one too long for it, or carrying parentheses or a newline the one-line shape cannot hold, leaves the date alone in the line with the body on the document. If the stamp turns out to be too much after a month of use, the fallback is the date alone always; but then search loses the answer, which is the reason the stamp exists. This is the decision in this feature most likely to want revisiting.

Its shape lives in notes/answeredStamp.ts, beside completionStamp.ts and built exactly like it: a few lines, importing nothing, because THREE scanners must now agree about it and none may import another — the to-do capture and the question capture refuse to re-capture a stamped line, and detectDates refuses to chip the date inside one. The writer stays with the questions, where the day format lives, so the shape module can never depend on the date layer that depends on it. What binds writer and readers is a fixture, not an import: stamp a line, assert all three leave it alone.

#A question is answered by a person, and then kept

A task is destroyed by completing it; a question is completed by being answered and then KEPT, because an answered question is a fact the household now holds. That is why questions get documents while people, dates and to-do captures do not: the answer changes somewhere other than where the question was written. It is also why an answered question keeps its row and gains its answer underneath rather than vanishing into a done pile.

A question inherits its note's shelf (groupId), which is what gives it a context clue that survives the note it came from: the row renders its folder first, then the person it is for, then its tags. And no surface badges it. Unanswered is an ordinary state — the app has no opinion about how long a question stays open, the same voice lateness gets and the same reason Tidy up is unbadged. Counts appear inside the More sheet, asked for by the tap that opened it, and never on a tab.

The model never answers a question. The assistant may one day propose question LINES — and accepting one writes the marker into the text, so capture keeps exactly one path and works identically offline with no key configured — but an answer comes from a person, and a model supplying one is predicting, which is the one thing this app's intelligence layer never does with somebody's own facts.

#A tag that names a group IS that group

A question already knows where it lives — its group, inherited from the note that asked it, which is the context clue that never goes missing. Tags say what it is ABOUT, which is often something its shelf does not name: a tile order asked in a Work note, a boiler question that belongs with the renovation. So both render as chips on the row, the group's first, and they are stored differently because they are different things — one place, inherited, moving when the question moves; many words, typed, belonging to the line.

Resolution is a LOOKUP over the live group documents through sameGroupName, never a derivation of an id from a name. The distinction is load-bearing: groupSlug is withheld from the public contract precisely so that no consumer can turn a name back into a document id, and this must not be the thing that quietly re-opens it. tagGroup scans what exists and answers a group or null, so it can only ever name a group that is really there. Names are unique across the namespace, so the answer is at most one.

A tag that names nothing is a concept, and stays a word: it groups questions on the index and filters a view, and that is the whole of it — no document, no cleanup, no orphan to garbage-collect. The day somebody makes a group with that name, every question carrying the tag starts pointing at it with nothing rewritten, which is the lazy half of the shared namespace doing what it already does for ensureTaskGroup.

Tags are never stored. detectTags reads the question's own text on every render, so there is no array to drift from the words and editing the question edits its tags — the pinned dates' seam, where the text carries the value. And they stop at questions for now: not on notes, which have full-text search and a shelf; not on tasks, which die. If notes come to want them the scanner is already written, but adding a second namespace of words to a surface that already has full-text search needs an argument nobody has made yet.

One refusal worth naming: a purely numeric token is not a tag. "issue #4" and "#1" are references, and a tag called 4 groups nothing anyone would go looking for — the chip scanners' charter again, miss before you lie.

#The assistant reads a note by asking for it

The snapshot names notes by title alone, and for a long time that was the whole of it: "what's the boiler service number?" failed with a note titled "Boiler service" sitting right there, because no body but the open note's ever reached the model. Now the turn carries a read_note tool — the model asks by the title the snapshot showed, the function fetches the note, and the same turn continues with its text.

Server-side, though a client-driven shape was designed first and discarded. The client already holds every body through the one notes listener, so the tempting version was two phases: the model answers "I need the Boiler service note", the client re-calls with the body attached. But that generates the whole turn twice — a throwaway reply, then the real one — at double the latency someone is sitting through; re-downloads any staged photos for the second pass; drops the model's own in-turn reasoning between the phases; and enforces "ask only once" by prompt, which is to say by hope. The tool loop buys all four back at the cost the seam had refused to pay: a continuation loop in anthropic.ts. The refusal stands where it was made — pause_turn from a SERVER tool is still a failure, silence still the designed degradation — and the client-tool loop that now exists is bounded structurally (two rounds, several reads per round), the way web_search is bounded by max_uses, not by asking nicely.

The title is the key because the title is what the model was shown — the annotate_task precedent, where the model echoes the snapshot and the echo is matched, never trusted as an id. Matching is folded equality (noteLookup.ts, foldForSearch's twin: case, accents, whitespace — exactly what a round trip through a model can lose), not canonicalize, which destroys prose, and not the search box's substring matching — "Notes search is substring matching, not the resolver" stays true, and so does its inverse: the tool hands over the note somebody named, never every note a fragment brushes. Duplicate titles take the first in snapshot order, so the model and the tool agree about which note a title means.

A read note is readable and nothing else. edit_note still reaches only the note on screen — the planner builds its editable note from the open route alone, and the tool's result never becomes one. Reading is cheap to grant because it cannot lose data; the day a read note can be rewritten, "tidy up the boiler note" deletes whatever the cut didn't show the model. Same cap as the open note (READ_NOTE_CHARS mirrors NOTE_CONTEXT_CAP) for the same reason — 8000 characters now; it opened at 1500, which cut ordinary notes mid-sentence and had the model saying, truthfully but unhelpfully, that it could not see the whole note, and eight thousand is about two thousand tokens, a cent of a turn. The cut carries a marker rather than ending mid-sentence, so the model can say how much is missing and knows not to offer a rewrite. And a per-turn cap — eight notes now; it opened at three, which refused fair questions spanning four recipes or five service notes — refused in words the model can read rather than an error that kills the turn.

The request now names its household so the function can prove membership before reading anything — the same gate the photos pass. A client from before the field simply gets no tool: the layer degrades to exactly what it was, which is the shape every seam here degrades to.

The first live run added two lessons. Asked "who is involved with info dispatch?", the assistant SAW the right note and offered to read it — "want me to pull details from that one?" — spending a whole turn on a yes the question had already given, and even told "pull from any note you need to" it read exactly one. The prompt was all brakes ("read what the question needs, not the shelf") and no gas: nothing said that reading IS the answer to the question, that several relevant titles mean several reads, or that the reads should arrive in one batch. Now it does.

The loop's bound moved with it, from two rounds to three, and the way it ends changed more than the number. Two rounds against a three-note cap meant a model reading one note per round died on its third read — the turn that had already paid for two reads thrown away as "couldn't reach the assistant". The cap is still structural, but its end is now told to the model — "budget exhausted, answer from what you have" — with one closing round to say so. A person two reads deep has bought the answer; the worst possible spend of their wait is an error. Only a model that stops for tools even in the closing round fails the turn, which makes that failure a refusal to answer rather than one honest read too many.

#The Questions sections ride the snapshot, answers and all

The assistant could read notes and still not know what the household had asked itself. A question's line stays in its note, so read_note can meet (now a question) and even a short answer's stamp — but the question DOCUMENT is where the record lives, and a long answer lives there alone: the stamp keeps only the date when the words would not fit inside its parentheses. "What did Sam say about the tile order?" was unanswerable by any path the model had, however many notes it read.

So questions join the snapshot as documents, not as note text — the same questions array the app subscribes once and hands every screen, folded by a layer-1 selector into two lines that mirror the Questions screen's own sections: open (newest asked first, capped at twelve, naming who each is for) and answered (newest answered first, capped at eight, the answer's words riding sliced). Not a read tool, because there is nothing to read — no note holds the record — and inventing a second retrieval surface for data that fits in two capped lines would be machinery in search of a problem. The caps split the roles cleanly: the snapshot is working context, the shelf on the Questions screen is the memory, and a question that falls off the answered cap still leaves its stamp in the source note for a read to surface — the prompt tells the model that is what the parenthetical means.

The assistant can neither ask nor answer a question, and the prompt says so rather than leaving it to be discovered. Answering is the sharp half: the type's own comment on answer reads "a person's words, never a model's", and a schema action that marked questions answered would put a model's words behind a stamp the household trusts to mean somebody said this. Asking is merely unearned — the ask syntax exists so a PERSON can flag what they need from another person, and a model with a question should just ask it in the reply, where the thread already is.

#A note nobody wrote in is not content

Notes archive rather than delete, because a note is often the only copy of something. That rule earns its strictness from the content it protects, and a note that was never written in has none: creating a note is a navigation here — the New button writes the document and lands you in it — so every abandoned open leaves a blank behind. The shelf filled with "Untitled" rows that were never anything, which is the reported bug.

So a blank note is dropped when its screen closes, and that is the single delete the app performs outside the archived shelf. The exception holds because there is nothing in one to recover; it does not generalise to "delete when it looks unimportant", which is how archive-not-delete would erode.

The guards all re-read the stored document rather than trusting the screen that asked, because the screen decided against the note as it was rendered and another device may have typed into it since. Whitespace counts as blank — a keyboard leaves a newline behind on a note that was only opened — but a title never does, since a title is typed on purpose and is the whole note when the body is empty. A note carrying a photo is never blank however few words it has: the photo is the content, often the only copy of what was photographed. That guard is also why the writer deletes the document alone and never cascades — the only notes it can reach have nothing to cascade to.

Deliberately no sweep of the blanks already sitting in a household's shelf. A background pass deleting documents nobody asked about is a different and much riskier thing than cleaning up the note you just abandoned, and the ones already there are reachable by hand from the shelf that was built for it.

#A photo sent is scaled for a model; a photo kept is scaled for an eye

Every photo taken in this app went through one constant: 1568px on the longest edge, the size vision reads a label at. For the receipt and pantry intakes that is the whole story — the bytes ride a callable's payload, get read, and are dropped, so a larger image buys a slower upload and nothing else.

A note photo is a different object with the same plumbing. Nobody attaches one to have it transcribed; they attach it to look at later — the small print on a prescription label, the model number behind the boiler, the handwriting at the bottom of a whiteboard. At 1568 that text survives transcription and dies under a pinch, and since the re-encode is destructive and the original is never kept, there is nothing to go back to. So the two sizes split: 1568 for what is sent, 2560 for what is kept.

Quality gives way before resolution when a photo is too dense to fit under the 2 MB Storage rule (0.8, 0.7, 0.6, and only then back to 1568). Grain in a photograph of small print still reads; a photograph scaled past the print does not. The steps stay inside drawScaled for the reason its file header gives in capitals: that function is what strips EXIF, and a path around it puts the home a prescription was photographed in into the bucket.

Thumbnails stay 4.5rem. The complaint was legibility under a zoom, and the thumbnail row's height is subtracted from the writing surface — paying for it in editor space would answer a question nobody asked.

The viewer's own pinch engine handles ctrl+wheel, so a plain wheel is relabelled into one and handed back — the app owns no zoom arithmetic. That listener has to be document-level and capturing, because the viewer renders in a portal, and the consequence is that it also fires over the footer, where the transcript is. A transcript longer than its 30dvh cap was therefore unscrollable by wheel: every turn zoomed the picture behind the words instead. The reading surface the "Read text" button exists to produce could not be read.

The exemption is a closest('.photo-footer') test before preventDefault, rather than a stopPropagation in the footer: a capturing document listener runs before anything in the tree could stop it, so the check belongs where the decision is made. Touch never needed the same treatment — antd-mobile renders the footer as a SIBLING of the touch-action: none stage rather than inside it, and the gesture engine binds to elements within that stage. The CSS says so out loud next to touch-action: pan-y, because it is one DOM change away from silently stopping being true.

#Tags widen to notes and tasks

"A tag that names a group IS that group" ends by saying tags stop at questions: not on notes, which have full-text search and a shelf; not on tasks, which die. That entry stands except for its last paragraph, which this one supersedes — the argument it said nobody had made has now been made, by the household asking for it.

The case against was that a note already has full-text search, so a second namespace of words is redundant. It is not, because the two answer different questions. Search finds every note CONTAINING "renovation" — the word in passing, the note that mentions it once to say it is unrelated. A tag is a claim somebody made: this is about the renovation. That is why the tag index is worth showing above a list and a search box is not.

"Tasks die" was the other half, and it is true — a to-do is completed and gone, so a taxonomy over to-dos would be a taxonomy over a population that empties. But the tag is not maintained: it is read from the words, so it costs nothing to have and nothing to clean up when the to-do goes. What it buys while the to-do lives is the crossing axis a shelf cannot give — the three to-dos and the two notes that are one kitchen job, filed in different places because they are different KINDS of thing.

Nothing about the grammar changed with the reach: one token, one fold, sameTag the only equivalence, numeric tokens still refused. The module moved from questions/ to tags/ because a name says who owns a thing, and it is no longer the questions'.

Notes and tasks render their tags as chips BESIDE the words, where a question row renders textWithoutTags and lifts them out. A question row is one line, so showing "#renovation" in the line and again as a chip reads it twice. A note row shows a clipped preview of a body nobody is reading in full there, and a to-do row shows text somebody is about to edit — pulling tokens out of either would be editing what is displayed rather than de-duplicating a line.

#The # menu is the @ menu

The tag menu could have been many things — a picker, a dropdown, a "create tag" affordance. It is a copy of the mention menu instead: tagAt beside mentionAt, the same two-character threshold, the same caret float, the same highlight, the same keys, TagMenu beside MentionMenu. Two completion menus in one textarea that behaved differently would be two things to learn about one gesture.

They share a highlight and one anchor because a caret sits after ONE opener: @ and # cannot both be under it, so there is no arbitration to design.

What it does NOT copy is the "someone else" row. That exists because a second Sam is a real thing to distinguish, and the menu must not silently apply the first one. An exact tag means the typing is finished; there is nothing to disambiguate and nothing to confirm. Nor is there a "create tag" row: a tag nobody has used is how every tag starts, and typing it whole is the path that never needed the menu at all.

The candidates come from everything the household has written — notes, to-dos and questions, newest first. Tags are not stored, so there is no collection to list and recency has to be read off the documents holding the words. Offering a tag only on the surface where it was first typed would make the menu's silence look like a decision about where tags belong.

#The flat view is a route, and it shows only what is open

Everything in this app is shelves. The doors show groups, a group screen shows its own rows, and a to-do is only ever seen where it was filed — which is how a household stops re-reading one endless list, and is the right default.

Both doors have one, and the notes' came second because it was asked for: the doors render one GroupCards precisely so they cannot drift, and a crossing view on one of them was exactly that drift. /notes/all is the same screen with the notes' own nouns — selectNotes gained the only set that selectTasks already had, so "in Renovation" narrows the same way on both, and everything else was already shared. The differences are the ones the surfaces genuinely have: notes sort by when they were last written in rather than by a day they name, and there is no archived shelf here — that is a recovery surface, and it belongs beside the notes it recovers rather than gathered from every group into one pile.

It is also the one thing shelves cannot do. "What is there, everywhere?" had no screen that could answer it: you had to walk into each group and hold the answer in your head. So /tasks/all is the crossing view, and it earns its place by being the exception rather than by replacing the doors.

A route, not a toggle on the door. A view is a place: it can be linked to, it survives a reload, it is where a tag chip on any to-do row lands, and Back means something. A toggle would have been component state that evaporates and that nothing else could point at.

It wears a switch anyway, and the switch is two links. The crossing view was a quiet row under the door, which said where it went and nothing about where you were — and two ways of seeing the same things is a choice, which reads as a segmented control. Making it one changed the affordance and not the architecture: ViewSwitch renders two NavLinks, so every property the route was chosen for survives intact, and what is gained is that both doors and both flat views now state which side is on screen. A toggle would have bought the same look and given all of that back. It sits ahead of /tasks/:taskListId because static outranks dynamic — otherwise "all" reads as a group named all, the same reason /notes/personal sits ahead of /notes/:noteId.

Open to-dos only, and no toggle offers otherwise. Completed to-dos are shown nowhere in this app: completing is the end of the thing, and a recurring task re-arms rather than completing at all. Adding "show completed" here would make this the one screen where finishing something leaves a residue to scroll past, which is the tidiness tax the whole design refuses. If a household ever wants a record of what was done, that is a different screen with a different name, not a checkbox on this one.

Every row names its shelf, as a chip that goes there. On a group screen that label would be the same word on every row, naming the page you are already looking at — so it is off there and on here, exactly as QuestionChipRow decides the same question. The note rows say it the same way now: where used to be text fused to the date, which told you where a note was and gave you no way to go there, and a group named two ways on two kinds of row is two things to learn.

No capture box. A to-do typed into a view with no shelf has nowhere to land, and asking which group first is a worse version of walking into the group. The view reads; the groups are where things are made.

assignedTo gets no filter, though the field exists on every task document. Nothing writes it. A filter over a field no screen sets is a control that always says "nobody" — the app promising something it does not do.

#The blank note teaches, and forgets

Five markers change what a line becomes — todo, ask, @handle, a bracketed date, #tag — and every one of them was documented only in the source of the scanner that implements it. The app would quietly capture a to-do from a line somebody typed by accident, and there was no surface anywhere that said the rule aloud. Discoverability was left to the changelog nobody read.

The blank note is where it belongs, on both counts: it is the only screen with room to say it, and the only moment saying it is useful — you are about to type, and this is what typing can do. A help page would be a place to go to be told about a feature you are not currently using.

It disappears on the first keystroke, and there is deliberately no dismiss. "Don't show this again" is a preference: something to store, sync, honour on a new device, and eventually get wrong. This needs none of that, because the condition IS the truth — the note is no longer blank, so the panel is no longer true. Deleting everything brings it back, which is right: an empty note is an empty note however it got that way.

Inert, and below the first line. The textarea underneath owns the caret and the keyboard, so the panel takes no pointer events and no focus — anything else would put a thing between a person and the surface they opened the note to use. It is absolutely positioned inside the editor's own box rather than sitting in the flow, because the editor's height is computed from what the shelf below claims (--editor-taken) and a panel in the flow would move the writing surface somewhere that arithmetic does not know about.

The examples are the real grammars, and that is a maintenance obligation: each line is a claim about a scanner in layer 1. A cheat sheet that drifts is worse than no cheat sheet, because it teaches a rule the app does not have — so the e2e run asserts the markers are all still named there.

#A preview quotes the line, not the document

Three surfaces derive a reference from text — the calendar (a bracketed date commits a day), a person's page (a handle names them), a to-do's way back (a marker captured a line) — and two of the three showed the DOCUMENT'S TITLE as the preview. A title is not why the row is there. "Kitchen" on September 2nd says a note called Kitchen mentions that day and nothing about what is happening, which is the thing a person opens a day to find out. The answer was in the line all along: the scanner found it, sliced nothing, and threw the offsets away.

personAppearances already did this properly, so it is the pattern rather than a new invention — NamingContext, the line split at the span that claimed it, the claiming words in the accent voice. That shape moved to src/lib/context.ts so the three surfaces share one slicer; three almost-identical line-slicers is how they drift apart.

For the calendar this meant surfacing spans detectDates had always computed and dropped (Claim extends Span, discarded on the way out). The span rides on DetectedDate now, and the dedupe hands over the winning phrase's own span — the committed phrasing beats an earlier mention, and the quote has to follow it or the row cites a line that puts nothing on the calendar.

The quote SUPERSEDES the bare phrase rather than joining it, because the quote already contains that phrase and printing both says the date twice.

And it is withheld wherever the line would only read the row back. A date in the title: the label already is that line. A recurring slot: its reason is a rule, not a line anybody wrote. A captured to-do: this is the interesting one, because it is the common case rather than the exception — a captured line IS the to-do's title plus a marker, so quoting "todo: chase the tiler (now a to-do)" under a row called "chase the tiler" is the app reading itself back. It earns the quote only when the line goes on after the pin, where the extra words live in the note and nowhere else. The feature is worth less on that surface than on the calendar, and saying so in the code is better than shipping a row that repeats itself.

#A swallowed failure is still written down

src/data/intelligence.ts catches everything and returns null. That is the layer's whole contract — an accelerant, never a dependency — and it is not up for revision: a household in a shop with bad signal should get the app it had before the layer existed, not an error to dismiss.

The cost was that "returns null" was also everything anybody could find out. App Check refusing, a missing API key, a household document that lost its memberUids, a model declining, a 90-second turn timing out, and a function deployed to another region were one symptom from inside the app: nothing happened. The one place a cause existed was Cloud Logging, which is not somewhere a person using the app can look, and the reported bug — the assistant stopped working and nobody could say why — is what that costs.

So the failure is recorded on the way past and the caller still gets null. Behaviour is unchanged by construction: the write is fire-and-forget, nothing awaits it, and nothing branches on whether it landed. It is the error path, so a failure to record a failure must not become an error of its own.

Firestore rather than Analytics. getAnalytics is deliberately absent from this app (bundle weight and a cookie-consent surface, for a household app that measures nothing) and Crashlytics has no web SDK. A collection under the household needs no new dependency, no new consent surface, and — because it rides the existing catch-all rule — no rules change, which is its own argument: a rules edit that has to be deployed by hand is exactly the kind of drift that produced this bug.

code and not a friendly message. The browser is told a FirebaseError code and nothing more; inventing prose for it would be the app guessing at a cause it does not have. The code distinguishes what the client can actually distinguish, and the real story stays in the function's logs.

A record, not an alert. No badge on Settings, no count, no red, and the section is absent entirely when nothing has failed — the same posture lateness gets. Something went quiet; the app says so if you ask, and does not follow you around about it.

The prune rides the write. There is no background pass in this app, and inventing one for housekeeping would be a moving part nobody asked for — so each write drops a handful of rows past thirty days, bounded because it runs on somebody's phone at the moment something has already gone wrong.

The writes joined later, for the same reason and one more. "Adding tasks without connectivity didn't work, but failed silently" was reported, and the first half did not reproduce: a to-do added with Firestore refused, hung, or the phone in airplane mode appears in tens of milliseconds, survives a reload, and lands when the path comes back — offline-add.mjs now proves all three, which nothing in the suite had tried. The second half was true, and had been true since serviceEvents was built: a queued write the server REFUSES when it finally arrives is rolled back out of the cache, the row leaves the screen, and void write discards the only thing that could have said so. The same shape as the model calls — something failed, the app was silent, nobody could find out — and it was the silence somebody actually watched.

So the writers own their promises. owned attaches a recording branch and hands the original back, so nothing about timing, ordering or awaiting changes; the writer names the write ("a to-do", "a note's text") because the seam that knows what it is writing is the seam that should say. The record is a service event exactly like the others, and the connection strip grows a chip — "a to-do wasn't saved" — because a refusal is not a live condition: it has already happened, will not un-happen by waiting, and is the one thing here a person should be told rather than merely able to find.

The chip is not redundant with the record, and the test that proved it was built to fail: when the refusal is the household itself being lost, the record cannot be written either, since it is a write to that household. The chip is what can still speak.

And the strip counts what is waiting. "Offline" was a word in the corner with its reassurance in a hover title, which is the one thing a phone cannot show. It is a button now, reading "offline · 3 waiting", and it opens to say those are saved on this phone and will sync — a claim with a number on it rather than a promise. The count is walked only when the query says something is pending, which keeps it to one boolean per snapshot in the ordinary case.

#Tags get a directory and a page, and nothing to write

Tags had chips and filter rows but no home: you could narrow a list by one, and could not ask "what does this household file under #kitchen?" without visiting each list in turn. People answered the same question for names — a directory behind More and a page per person, appearances shelved by group, each with the line that named them — so tags follow it, screen for screen.

What it does NOT copy is the writing. A person is a document: Rename and Merge exist because two documents can mean one person, and a name can be wrong. A tag is the word somebody typed, and the word is the whole record. There is no document to rename and no duplicate to merge — #Renovation and #renovation already fold to one — so the page has no header actions, and renaming a tag means editing the lines that say it, which the page opens each of. That is the honest version rather than a limitation: a "rename tag" that rewrote prose across every note would be the app editing what people wrote.

The directory carries a count where People's does not. A directory of names is looked up; a directory of tags is also surveyed — "which of these are still alive?" — and the number is the answer the row is opened for. Counted over the same live/settled lines as the page, so a row and the page it opens agree.

Row chips go to the page now; index rows keep filtering. Before this a tag chip on a note row narrowed the list it sat in, which was the only thing it could usefully do. A person chip has always gone to the person's page, and the two chips should mean the same gesture: this thing is about that — show me that. The filter is still one tap away, in the index row above the list, where filtering a list belongs.

#Search looks down, never up

Every door and group screen wears one search box at the top, and the ask was that it be unified: one box that finds a note, a to-do or a folder alike. The question that decides its shape is what "here" means when you type into it. Three answers were possible: this screen's own rows (what the notes screen did), everything in the household (what a global search does), or this level and everything under it.

The last one is the one that makes the tree mean something. A screen is a place in the tree, and a search from a place is a search of that place: the door sees everything, a root group sees itself and its subgroups, a subgroup sees itself. Typing the same word on two screens gives two answers, and the difference is exactly the difference between the screens — which is what makes Back a way to widen a search, and a card a way to narrow one, without either being a control. A search that reached above the screen would make every level the same screen with a different title; one that stayed on the screen's own rows would make the folder a wall between a word and the note behind it.

Folders come back as results because a name you half-remember is as often a group's as a note's, and because a found folder is the cheapest way to say "the thing you want is in here". A found card is the card its own level would show — count, hold sheet and all — with where it lives in place of its hint, so nothing is rendered that the screen does not already know how to render. The door's document hits are links rather than rows for the same reason in reverse: a door never renders content, and a search result that could be ticked off from a screen that otherwise shows only doors would be the one row on it that behaved differently.

The controls that add to a level — the search, New group, the capture — sit at the top of it, together, because they are one question ("what goes here?") and the list under them is the answer. Reading past a list to find the box that adds to it was the shape being corrected.

#A chip inside a row is a label, not a button

The app's chip was written for strips of its own: the note page's facts strip, the join offers, the filter rows — places where the chip is the thing on the line, so it wears a full tap height and a bold face. Then chips started appearing INSIDE rows: a question's shelf and tags, a to-do's facts and the note it came from, a note's tags. There they are one of several things on a line that is not about them, and at full size they were the loudest thing on the row, sat off their line (an inline anchor with block padding), and pushed the row's own words around.

So a chip inside a row is a second design, .chip.compact: one size down, no tap height, centred by construction, clipped with an ellipsis rather than wrapping. One design for every row — to-do, question, note — because a person reading a list should not have to learn that the same idea is a pill here and a green word there. The to-do rows had been drawing theirs as .linkish, the inline verb; that word now belongs to Answer alone on a question row, which is right: Answer is the one thing on that row that DOES something, and once the chips beside it are quiet pills it is the row's only accent word.

A note's row states what it carries with the shelf line's own icons and counts, and nothing else: no pill, no disclosure, no chevron. The page's shelf line opens; the row only says. That needed one field the row could not derive — the photo count, whose receipts are a subcollection — and it is a read cache on the note, moved by increment and healed upward by the page, in the pattern purchaseHistory set: never the truth, always rebuildable from it.

#Search and filter live behind the header

Every list screen had grown a strip of controls between its title and its list: a search box, a row of group chips scrolling sideways, a row of tag chips under that, and a sort icon. On a phone the list started a third of the way down, and on a screen you open to read, that third was spent on things you rarely touch. So the controls went where the app already keeps its rarer verbs — behind an icon — and the resting screen is only its list.

The People directory is the one exception: its box is always there, with no icon and no Cancel. A directory is opened to look somebody up, so the search is not a rarer verb behind the title but the screen's first control — and a list of names under an open box is still only a list.

Two icons rather than one menu, because search and filter are different gestures: search is typing, filter is picking. Search opens a bar under the header, focused, and stays open only while there is a query — so a result's Back lands on the same search, and a screen opened by a link with a query in it shows the bar without raising the keyboard. Filter opens a bottom sheet, the app's own answer to "choose one of these" since the held sheets: thumbs reach it, and a two-level tree gets height a chip row never had. A popover anchored under the icon was drawn and rejected for the phone: a box that size over a list is cramped, and a tree scrolling inside it is a second scroll inside the first.

Tags moved INTO the search rather than into the sheet. The app already treats #tag and @person as words in prose, born by being typed; a box that reads them the same way is one grammar rather than two controls, the query stays a single string in the URL, and the sheet loses a whole section — short enough that a tap applies and there is nothing to confirm. What it gives up is browsing tags from inside a list; the Tags directory keeps that job, and a half-typed # offers the tags in view as completions, which is most of the browsing anyone did.

An applied filter is spelled out under the header as one chip wearing its own ✕, and as a dot on the icon. A screen narrowed to Kitchen must say so where the eye lands, or an empty list reads as an empty household.

The calendar had given up its title bar ("Calendar" over "August 2026" said the same thing twice) and kept its group picker as a chip strip under the month for want of anywhere else to put it. The title bar is back for the filter's sake: the header is where every other screen keeps it, and a calendar that alone hid its picker in a strip was the one screen the gesture did not carry to. The month bar under it is still the calendar's name; the title above it costs one row and buys the icon.

#A nickname is an alias somebody meant

People already answered to more than one word: aliases holds the folds a rename or a merge left behind, so @sam keeps meaning Sam Chen after the surname arrived. A nickname is the same mechanism pointed the other way — a word the household chooses rather than one history left — and it would have been tempting to store it as one more alias. It is its own field because it is shown and edited where aliases are hidden, and because it has a job the aliases never had: it is the word the @ menu types. Completing @sa to @sam-chen when the household calls them Sammy would be the app insisting on its own spelling; a nickname exists to be shorter, so when there is one it is what gets spliced, and the menu shows it beside the name so nobody is surprised by the word that lands. Taken away, it stops resolving — unlike an alias, which stays because prose already depends on it — because a nickname was never in any note by the app's doing, only by the writer's.

#The door folds as one

"Nothing folds, because nothing nests on screen" (above) took away the per-card carets, and a card is still a door: what is behind it renders behind it, one tap in. What folds now is the CONTAINER — the whole level's group list, behind one title row — and that is a different thing. On a group's own screen the subgroup cards sit above the group's content, and a Work with a dozen subgroups pushes the notes or to-dos you came for a screen below the fold, every visit, whether or not you came for a subgroup.

So the container gets the title it never had — "Groups", the count, a caret — and the title is the toggle. Folded, the count stays, so a put-away door still says what is behind it; the hold hint goes with the rows, because it describes a gesture on something no longer on screen. The fold is remembered on the device, per door and per level, the way the open shop section and lastStore are: a fold that reset on every visit would be a chore to repeat rather than a preference, and a fold shared through the household would be one member deciding how the other's phone lays out. Nothing inside the container folds, and no card grew a caret back: this puts away the list, not any one entry in it.

#The bucket asks Firestore who you are

Photo authorization was a capability — signed in plus an unguessable path — because the cross-service firestore.get() had been found dead under the emulator, and a rule the suite cannot exercise rots. Both halves of that deserved a second look. The lookup was re-tested against the current CLI (firebase-tools 15) with a suite written for it: the Storage emulator sends the Firestore emulator the owner token for a local address, the document comes back, and a member uploads while a signed-in stranger who knows the exact path is refused. What had looked like the bridge forwarding the lookup unauthenticated turned out, on this machine at least, to be an HTTP proxy between the CLI and its own emulator — an environment, not a limitation.

So the gate is membership again, read from the same memberUids the Firestore rules read, and a photo has exactly the readers and writers its note has. The capability argument was never wrong about reads — a download URL carries its own token and an <img> never meets a rule — but it was thin on writes: any Google account could create and delete under any household's path it could guess or be told, and "unguessable" is a property of the generator, not of every phone, screenshot and bug report the path passes through. tests/emulator/storage.test.ts is what keeps the rule exercised, and npm run test:emulator now starts the Storage emulator so it can be.

#A person has a daily allowance of model calls

App Check proves the caller is this app and auth proves who they are, and between them they left one thing unbounded: how much one person can spend. Every callable fronts a billable key, and reviewPass reads a whole dataset at high effort with nobody watching — a member in a loop, a stuck retry, or plain curiosity could run the household's bill up without limit and nothing would say no.

Now every call costs units against usage/{uid}, a Firestore document only the functions can touch, inside a transaction so two calls at once cannot both read the same balance and both pass. The prices are relative — one for a label, three for a photo, twenty-five for the review pass — against three hundred a day. The shape was chosen so a heavy day never notices and a loop runs out fast: a hundred typed items, ten photos and twenty chat turns fit several times over, and the dearest call gets a dozen goes rather than a thousand. The day is UTC, so every region ticks over at once and the function needs to know nothing about where the person is.

Exhaustion is not a new state for the labelling seams. resource-exhausted reaches them as null, exactly like offline or a refusal, and the app carries on with statistics and manual entry — the layer was built to be an accelerant, and a budget is just one more way for it to be absent. The one place a person is sitting and watching, the assistant drawer, deserves the truth: the platform's error codes are a closed set and a rate limit already uses this one, so the function attaches {reason: 'budget'} and the drawer tells the two apart. "Busy, try again in a minute" is the wrong sentence for an allowance that comes back at midnight, and a wrong sentence there costs a person a minute of retrying and then the app's word.

#A check-off never waits for a read

ensureSession used to read the open session from Firestore before answering, and the check-off awaited the answer. Online, a round trip; offline, the SDK serves the cache and the wait is nothing. The case that neither word covers is the one a phone in a supermarket lives in: a connection that is up but not answering, where getDocs waits on the server for ten seconds or so before it gives up and consults the cache — and the thing it was holding up was the row moving when someone tapped it. src/data/items.ts had promised that never happens. The session path broke the promise one layer up.

The listener already knew the answer. subscribeOpenSession delivers the open session from the cache when offline and from the server otherwise, so ensureSession now reads that snapshot and returns synchronously: the known session if it is still live, the trip this device just opened if the listener has not echoed it yet, or a fresh one with a client-generated id. The writes never waited — that was already the rule — and now nothing between the tap and the writes does either. The opened ref replaces the in-flight promise that used to keep two quick taps on one trip; it does the same job without a promise to await.

What this does not change: two devices opening a trip in the same minute can still each open their own, since neither can see the other's write before it lands. That was true before, and the sweep and the recent-trips screen are where it gets tidied.

#The checked-item listener carries thirty days

Every list item ever ticked off stays a document — completed, not gone, is the rule for the trip on screen — and the checked-item listener read all of them: where('checkedAt', '>', 0), the household's whole shopping history, from disk on every boot and re-sorted on every snapshot. It was the one set in the household that only grew, and the first place the subscription model would have been felt (BACKLOG, Performance): a household ticking thirty items a week is past a thousand documents inside a year, for readers that never look further back than a fortnight.

Every reader looks at the recent past. tripChecked wants the trip still running, minutes. listedTrips wants the trips worth attributing, days. Promotion wants three adds of the same thing, and an add that far back is not the habit it is looking for. Thirty days covers all three with room, and bounds the listener by the calendar instead of by how long the household has been shopping. CHECKED_WINDOW_DAYS lives in layer 1 beside the session gap, so the screen that says "the last 30 days are here" and the query that makes it true read one number.

The cutoff is fixed when the listener opens and holds for its life, rather than re-issuing the query as the clock moves: a re-subscription costs a full re-read, and the difference — an item crossing the boundary while the app stays open — is invisible on every screen that reads the list. A single-field range on checkedAt needs no composite index, so firestore.indexes.json is unchanged.

What changes for a person: Recent trips lists the last thirty days rather than everything, and says so where it used to say nothing expires. A trip older than that keeps its purchases and its shop; it is simply no longer offered for re-attribution, and the "without a shop" count on Settings counts the same thirty days. Promotion needs its three adds inside the window. Neither reader had a limit before because the listener had none, not because either wanted the whole history.

#Look back is a household setting, and ninety days is where it starts

The entry above windowed the checked-item listener to a constant. The household wanted the number to be theirs, and the same idea everywhere else the app reads its whole history to derive something: the calendar, the People and Tags directories and pages, the Questions index, the chips between a note and its to-dos, the @ and # menus, and what the assistant is shown. None of those had a window at all — every one scanned every live document on every render, and the first household to keep notes for three years would have found out.

One number plus overrides, not five numbers. "How far back should the app read?" has one answer for most households, and a row per surface under Name and Region would have made the section a form. So the shelf holds Everything and, under it, a row per surface that is blank until somebody wants Trips shorter or the calendar longer — blank meaning "same as everything", stored as null, so the document says which rows are the household's own and which are following.

Ninety, fixed, and applied from day one. "Unlimited until set" would have kept every household on the unbounded scan the setting exists to end, and a default is a claim the app should make plainly rather than defer. Three months holds a household's rhythm — the shops it keeps, the people it names this season, the plans that are still plans — and it is what the checked listener can carry without reading the household's whole shopping life at boot. It is also three times the thirty the previous entry chose for trips: a deliberate cost, roughly three times the checked documents on a cold start, paid so that one default serves every surface. A household that wants the old cost sets Trips to 30.

Documents are never hidden; derivations forget. An old note stays on its shelf and opens as it did; an old to-do stays on its list; the archived shelf still finds everything. What changes is what is READ OUT of them: the tag it carries stops being counted, the person it names stops appearing on their page, the day it names leaves the calendar. Hiding the documents would have made the setting a data-loss control with a friendlier name.

The calendar keeps a plan. A pure document-age cut would take an old note's "[Dec 25, 2026]" off December because the note was last edited in August. An entry leaves only when the document AND the day are both behind the window: a day that has passed in a note nobody has touched since is a record; a day still ahead is a plan whatever the note's age. Recurring slots are exempt entirely — a weekly chore made a year ago is re-armed by every completion, so its createdAt says nothing about its age.

Questions are hidden by when they were asked, and never styled. An open question asked before the window leaves /questions, the group shelf, the More sheet's count and the assistant's snapshot. That is the one place this setting can surprise — a question nobody answered in ninety days goes out of sight — and it is still the right shape: hidden is not marked, the rule lateness lives by, and the row can be widened. The question's own chip on its note is per-note and stays, whatever the note's age.

The menus follow the window; the search does not. The @ and # menus and the search bar's tag chips offer what the household has written INSIDE the window, because a word nobody has typed for a season is not what this household is writing about. But a typed #tag or @name still finds every document, old ones included: matchesSearch is per document and is not windowed, so the way to an old note is to ask for it.

The recent-sessions listener stays whole. learnedStores resolves a staple's newest five purchases to shops through it, and a staple bought every three months spans fifteen months in five purchases. A ninety-day window there would blind shop learning for anything bought less often than monthly. Old trips fall out of Recent trips anyway, because their checked items — the windowed set — are gone and listedTrips lists only trips that bought something.

Fixed per household snapshot. useLookBackWindows reads Date.now() inside its memo, so since moves when the setting moves and not with the clock: the checked listener's own rule ("the cutoff is fixed when the listener opens"), now every surface's. The trips listener waits for the household document rather than opening at the default and reopening at the stored value a moment later — a reopen is a full re-read, a derivation over the default is free.

The server makes the same cut. The assistant's snapshot lists note titles inside the window; read_note fetches bodies server-side from the whole collection. Cutting one without the other would have let the model read a note it was never shown, or be shown a title the tool would refuse. So the request carries notesSince, and notesWithin applies it in the function, reading a note with no updatedAt as edited when it was made — the client reader's own default, so both sides agree on every note. A client from before the field sends nothing and gets no window, the missing-hid rule.

The reader is total (readLookBack) for the same reason every household reader is: the update rule validates no field, so a member can write lookBack: 'banana' and the app has to run on it. The writer clamps to whole days in [1, 365] so the document never holds a value the reader has to repair, and the Settings row reads back what was actually kept — a typed 9999 shows 365 on the next snapshot, which is the honest feedback and needs no red.

#A note row shows its photos and nothing else

The row used to wear the page's whole shelf line — photos, facts, dates, to-dos, questions, people — as icons and counts on its title line, derived on every render by the same six scanners the page runs (noteCarries). It read as thoroughness and worked as noise: a list of forty notes was forty rows of small numbers, and nobody on a list is deciding whether a note has two phone numbers or three. The title and the snippet say what a note is about; the page says what it holds.

The photo count stays, alone, because it is the one kind a row cannot say any other way — a title cannot hint at a photograph, and the snippet is text — and the one the note already carries as a read cache (photoCount), so it costs the row nothing to show. Everything else went with the module that computed it: the six scans per row per render were the largest derivation on the notes door, spent on numbers that were not being read.

#Every way of adding to a level is one line

"A rare box waits behind a link" put the group box behind New group. The level still had two other ways of adding, in two other places: New note in the header, competing with the title for the one slot a phone's title bar has, and the to-do box always open under a group's title with the Repeats controls beneath it — the loudest thing on a screen whose job is to be a list, shown whether or not you came to add. So the three moved onto one line under the header (ActionLine): New note or New to-do, then New group where one may be made. The header holds the title and the tools; the line holds every way of adding; the list that follows is only the list.

A note's link is a plain action, because making a note is a navigation — there is nothing to open. The to-do link opens its box under the WHOLE line, never under its own link, so the line does not break in two and the other links stay where they were; the Repeats rule rides inside the box and is forgotten when the box is put away, since a rule set for a to-do nobody typed is not a preference. One box open at a time — opening the second puts the first away — because two open boxes are a form and this is a line. The doors carry New group alone, a note or a to-do needing a group to land in; a subgroup's line carries only its own New, the ceiling as before.

#What is done to a person sits under the name, not beside it

Rename and Merge lived in the person page's header, two words sharing the title bar's one right slot with the name itself, while the nickname — the third thing done to a person — had its own line underneath. The header now holds the name and the way back, which is what a header is for, and the line under it holds what is done TO the person: Rename, then Merge where there is anyone to merge with. What is TYPED about them — the nickname and the job — sits under that line as fields, always fields (see the job's entry below): a page opened to fill something in should show the box, not a link to one. A rename takes the tools line's place while it runs, and Merge still opens its card below — a merge deletes documents, and it keeps the room it had.

#Per-area limits sit behind a switch, as fields

The Look back shelf shipped as six tap-to-edit rows: label, hint and value on one line, only the label a button. On a phone that was three things fighting for the width, and the thing a person reached for — the value, "Same as everything · 90" — did nothing when tapped. Rows that edit in place are right for Name and Region, which are read a hundred times for every time they are changed. A number somebody came here to set is a field.

So each is a field now, always a field, with the label and its hint on one line and the number on the next, the unit inside the box's edge. And the five per-area fields sit behind a switch: at rest the shelf is one field and one switch, which is what nearly every household will ever look at. The switch is a stored flag rather than "any area has a number", for two reasons. A household that turned it on and left every field blank has still said something — they want the fields there — and the shelf should come back as they left it. And a number typed into a field that is then hidden must never act: the switch off means everything follows Everything, whatever the hidden fields hold, so switching off and on again finds the numbers as they were and nothing acted in between. A document from before the flag reads as switched on exactly when an area holds a number, so nobody's window moved on upgrade.

The switch is a native checkbox wearing a pill, role="switch", for the Repeats checkbox's reason: antd-mobile's own hides its real input, which the e2e locators cannot see, and accent-color already themes native controls. Skinned in CSS rather than drawn, it stays one focusable, checkable control.

#The drawer follows the newest words until you look away

The assistant's log pinned to its end only when a message landed or the streamed text changed length. Everything else that grows the log while a turn is in flight — the "searching…" and "reading…" lines, the dots giving way to them, the summary under a finished reply, a failure notice, the photo picker or the keyboard shrinking the sheet — grew it below the fold, and the newest thing on screen was the one thing out of sight.

The rule is now the one a chat is expected to keep: follow the newest words while the reader is at the end, and stop the moment they scroll up. A ResizeObserver on the log's content is what makes every one of those growths count as newest words, since they all change its size and none of them are messages; a ref set from the log's own scroll events (atEnd, in layer 1, with a few pixels of slack for fractional positions) is the "unless". Sending sticks it again, because your own words and the reply that follows are the one thing that must always come into view. Still scrollTop on the log and never scrollIntoView, which walks every scrollable ancestor and would drag the page behind the mask.

#A job is a nickname-shaped fact, and its box completes from everyone else's

A person's page carried two things you could type about them: the name and the nickname. What they DO — plumber, letting agent, Sam's teacher — is the third thing a household keeps about the people it names, and the one most often needed at the moment a note is opened ("who was the plumber again?"). So it is a field on the person document, the nickname's own shape: one field, null until filled, blank reads as null, never in any note. It is a field of its own under the tools line, in the Look back fields' clothes, and the nickname became the same kind of field beside it: both are typed rather than toggled, both are what a page is opened to fill in, and an "Add a …" link was a tap in the way of a box that could simply be there. Unlike the nickname it resolves nothing — @sam is still the only way to name Sam — because a job is a fact about a person and not a word the household types for them.

The box completes from the jobs already written for other people, the way the @ and # menus complete: a household's people share jobs, and the second plumber should reach for the first one's word rather than leave "plumber", "Plumber" and "plumbing" as three. Prefix on the search fold, newest person first, chips under the box while it is focused and the typing is not yet a job, picked on pointer-down so the pick beats the blur and committing the word whole. No "new job" row: typing a job nobody has yet is how every job starts. A merge carries a job to a survivor who has none, since it was a fact about the same person.

#A person nothing names can be removed

People are born by prose and stayed forever: the only way out was a merge, so a name typed once by mistake — a typo, a plumber never called again — lived in the directory with nothing pointing at it. Removal was withheld because a mention is the link, and deleting a person under a mention would leave @sam resolving to nobody with the words still on the page.

So removal is offered exactly when that cannot happen: when nothing in the household names them. "Nothing" reads every document in every state — archived notes, completed to-dos, answered questions — and ignores the look-back window, which is a rule about what a screen derives and not about what exists; and it resolves through aliases, since a merged-away spelling is still a spelling. The offer is a word on the tools line, asked once in the merge card's shape with the consequence stated, and a delete with no undo — because there is nothing to undo: the name typed again in any note births the person whole, as the first mention did.

#The splice says the name and the nickname

The @ menu used to type the nickname when there was one — @sammy — on the reasoning that a nickname exists to be shorter. But a handle in a note is read by whoever opens the note next, and @sammy alone is a private word: the person who set the nickname knows who it means, and nobody else does. So the menu types the whole name, and the nickname rides beside it in parentheses, glued to the token: @shiyi_liu_(Alice). The text then says both what they are called and what the household calls them, and the scanner reads the parenthetical as one more fold, so either half finds the person — the name after a rename, the nickname after a merge. A name typed by hand in the same shape births the person with that nickname, since the writer just said it. Underscores between the words, because that is what the household wrote and because a hyphen inside parentheses reads as a dash; both fold to the same word.

#An account is a person who said so

The app knew who was signed in and it knew whom the household named, and nothing joined the two: a person's page could show everything naming Sam, and no screen could show everything naming me, because "me" was a word the app had no referent for. The join is one field on the person document — uid — written from the person's own page by the one tap that can honestly write it, "This is me". Not a picker in Settings, because the claim is a fact about a person and the person's page is where facts about them are typed; Settings shows the answer under You and walks there.

Why the person document and not the user document: the household is what names people, so a person is a household document, and an account can be in more than one household, each with its own cast. A personId on the user document would say "in which household?" and have no answer. On the person, the claim is scoped by where the person lives.

What "mine" then means is what already meant anything here: a mention. A to-do is Sam's because its text says @sam, whoever typed it — the same sentence the person page has always used, so the "Mine" filter and the page agree by construction — and a question is Sam's when addressed to them or naming them, the page's rule again. assignedTo, a field on every to-do since the schema was written and never once set, stays unset: a second way to say whose something is, invisible in the text, would make the row and the words disagree, and the app has spent every decision so far keeping the text the truth.

One person per account, enforced in the gesture: claiming another entry releases the first, because "mine" meaning two people is worse than meaning none. Somebody else's claim is left alone and unsaid on the page — one member's sign-in is not another's business — and a merge carries a claim to the survivor as it carries a job, since the merged-away entry was the same person. The filter is offered only once there is a claim: before that, a "Mine" that could only ever show nothing would teach a rule the app does not have. And the assistant is told, in one line at the top of its snapshot, who is typing and the handle the household writes for them — so "my to-dos" resolves to a name, and a to-do it adds for the asker names them in its text, where the assignment lives.

#A trip that came home without it is a sentence, not a verdict

Every closed trip has recorded, since the first build, the regulars that were on a list when it ended and never got checked off. The field was written for out-of-stock detection and read by nothing, on the honest grounds that a claim about a shop's shelves from a phone's list is a guess. It stays a guess; what changed is how it is said. A regular carried unbought across the last two trips to its own shop gets one line, in the muted voice, on its Regulars row and under its Shop: "Not found at Kroger on the last 2 trips there." That is a fact about the trips. Whether the shelf was empty or the list went unread is the household's to know, so the app states the trips and stops — no "out of stock", no badge, nothing to dismiss, and nothing written anywhere.

Consecutive, and at one shop, because that is what makes the sentence mean anything. A trip that bought it ends the run; so does a trip that did not have it on the list, since nothing was left behind; a trip somewhere else is not evidence about this shop and is skipped rather than counted either way. The shop is the regular's own — the pin, or what its purchases have taught — because "unbought on three trips to three different shops" is a list nobody read, not a shelf, and a regular filed under Anywhere therefore says nothing here. Two is the threshold: one trip is a hurry or a reshuffle, and below two the output is silence, the app's answer to every prediction it cannot stand behind.

#Order is a key between neighbours

A group's rows ran oldest-first because the listener sorted them so, and a household that wanted tonight's errand above last month's had no way to say it. The ask was reordering; the question was what to store. A position — 1, 2, 3 — is the obvious field and the wrong one: moving one row renumbers every row under it, which is a write per row on every move, and two phones each moving a different row offline write two numberings that collide. So the field is a key, not a position: a string compared as a string, base 62 so that comparison is arithmetic, with a midpoint between any two — a row moves by taking a key between its new neighbours', and nothing else on the screen is written. Two offline moves of two rows land as two keys, and both stand.

The order is a rule over the keys, not a replacement for the old one: keyed rows first, in key order, then everything unkeyed, oldest first as before. That sentence is what makes the field a no-op until used — a group nobody has touched reads exactly as it did — and what puts a new to-do at the bottom whether or not the group has been reordered, which is where new things have always gone. The cost is paid once: the first move inside an unkeyed stretch keys every row on the screen, because a neighbour without a key cannot bound a midpoint, and after that every move is one document. A key is a place among THESE neighbours, so it means nothing in another group and a move between groups clears it; likewise the flat view, whose rows come from many groups, reads no keys.

The gesture is the hold the row already answered to, grown into the calendar's menu — up, down, another group — on a group's own screen at rest, and left as it was on the flat view and mid-search, where the rows are not one group's and a place among them is not a place anywhere. Up and down rather than drag, because a held row that also scrolls the list is the gesture the hold hook exists to tell apart, and because the ask was to put one row above another, which is one tap. Notes stay sorted by when they were last written: that half of the ask is still open.

#A person's page reaches sideways through what it already reads

The ask was a way from a person to everything connected to them, with seven candidate crossings and none chosen. Two are built, and the choice was made on one question: what can the page already answer without a model and without a new index? It runs the mention scanner over every text naming the person; the people those same texts name are one more loop over the mentions it has in hand. It renders the facts scanner's chips on every note; a fact is identified by its href already — the same number written two ways is one call — so "everything else with that number on it" is one map keyed the way the chips are. Both are derivations over the live documents in the page's own window, and both vanish with the mention that put them there, personAppearances' rule.

What is left out is left out on purpose. A fact only the person's own texts carry leads nowhere the page does not already go, so it is not offered. Shared tags have a directory of their own one tap away. Linked to-dos and questions already wear chips on the rows. Same-day and shared-word rankings need a notion of "related" the app would have to invent, and asking the model is a seam that would return null in this checkout and every test. The five stay in the backlog as options, in the words they were asked in, so the next person does not spend an afternoon rediscovering that two were built and why the rest were not.

#A fold closes faster than it opens

Every place in the app that opened or closed did so in one frame, and the six <details class="shelf"> sections had no sign they opened at all: the summary is display: flex, which drops the browser's disclosure marker, and nothing had replaced it. "Might be running low (3)" read as a heading.

The motion that fixes this is a vocabulary, not a set of local choices — the durations in base.css were 0.1s, 0.15s, 0.2s and 0.3s, each picked where it was typed, all with plain ease. tokens.css now names three durations and two curves, and three rules govern how they are spent.

Closing is faster than opening (150ms against 200ms). Opening is something a person watches: the rows arrive, and the eye follows them into place, so it decelerates into rest. Closing is something a person has already decided; the eye has left for wherever it is going next, and a fold that lingers on its way out is in the way. The asymmetry is what "snappy" means in practice.

Nothing bounces. No overshoot, no spring, no pulse. Motion in this app is furniture moving, never a notification — the same posture as "Lateness is never marked": nothing on screen may use movement to claim urgency.

Reduced motion is one rule at the root. The toast and the chat dots each carried their own media query, and anything added later had to remember to. Setting the three durations to zero under prefers-reduced-motion keeps every end state and drops every travel, present and future, in one place. Anything that unmounts after a close does so on a timer matched to --dur-close and never on transitionend, which under that setting never fires — the toast's bargain, now the rule.

The <details> shelves were first left native, animated through ::details-content with interpolate-size as a progressive enhancement. That was wrong for the shelves in particular: they are the collapsibles people tap most, and the enhancement is Chrome's alone, so on every iPad and iPhone they snapped while the group door beside them folded. Shelf keeps the element and hands the body to Fold, the one motion in every browser the app runs on.

#A fold keeps nothing mounted and moves nothing on first paint

The obvious way to animate a body that opens and shuts is to keep it in the DOM and transition its height. It is the wrong way here twice over. A closed shop group with sixty rows would render sixty rows nobody can see, on every change to the household, which the performance rule forbids — and a height to transition has to be measured, which is a layout read per toggle and a number that goes stale the moment a row is added.

So Fold renders nothing when closed and settled, exactly as the expanded ? <ul/> : null it replaces did, and animates a grid track instead of a height: grid-template-rows from 0fr to 1fr interpolates in every browser the app runs on, and needs no number.

Nothing moves on first paint. The door restores its fold from localStorage; the list opens the likely shop on load; a trip or a search can arrive open by link. Had every open body animated in on mount, every screen would visibly assemble itself, and a body arriving by itself reads as the app doing something rather than the person having done something. Only an open that flips after the first render — a tap — earns the enter. The mechanism is a data-motion attribute present only while the body is moving, which @starting-style reads to begin from nothing; a body open at mount never carries it.

Unmounting waits on a timer. transitionend is the natural signal and the wrong one: under prefers-reduced-motion the durations are zero and the event never fires, which would leave every closed body on screen for good. This is the toast's bargain, made once more and for the same reason; the timers in Fold.tsx match --dur-open and --dur-close, and read the setting to go to zero with them.

The flip is derived during render. The frame in which open becomes false must already show the body leaving. An effect runs after that frame has painted, by which time a body rendered as open ? … : null is gone. Setting state while rendering makes React re-render before committing, so the same frame carries data-state="closing" and the track begins to shrink.

The body is clipped only while data-motion is set. A permanent overflow: clip would cut the focus ring of any row inside; at rest there is nothing to clip, so nothing is.

One motion, the fold. A fade-in-place variant was tried for the bodies under which something else already moves — the keyboard rising for a box that takes focus, the note editor giving its strips room by arithmetic — on the theory that two things moving at once read as jank. In use it read as nothing happening: what a person notices is the content beneath moving down and up, and a body that is simply there at full size with its paint arriving is a snap with a delay. So every body folds, the search bar and the capture box included, and the note editor transitions its own height at the fold's timings so the strips and the writing surface move as one and the page never shows a gap.

#The shelf's closed line is the short row

Every note that carries anything — a fact, a date, a photo, a to-do, a person — pays for the shelf's closed line out of its writing surface, on every visit, whether or not the shelf is ever opened. It stood at the facts strip's height (3rem) with a full-height chip on it, because it was born as one more row of that strip. But the folded note is the common case, and the closed line does one thing: it says what is here and opens on a tap. It does not need a row built for a scrolling strip of action chips.

So it is its own row, shorter (--shelf-row-h, 2.25rem), pulled up under the editor to a smaller gap (--shelf-gap, the screen head's own trick against the stack gap), and its chips wear the compact 2rem the tool bar's chips already use. A folded note gains a line and a quarter of writing.

The floor is the tab bar, and the room above it is deliberate. The stack's bottom padding keeps 2rem of ground between the toggle and the tabs, so a thumb that aims low at the toggle finds nothing rather than Notes or Calendar; the chip is 2rem tall, the tool bar's compact size, and no smaller — a toggle nobody can hit is a shelf nobody can open. What was taken came out of the row's dead height, not out of its target.

#Joining is a code on the document, not a function

The invite flow was skipped at the start (§13) because it could be added at any time without a migration. Adding it raised the question of where the admission check runs. A Cloud Function is the obvious place — it can read the invite, check the code and enroll the caller with the Admin SDK — and it was refused for the reason every callable in this app is optional: the functions enforce App Check, and without a site key every call is a round trip that cannot succeed. Joining a household is the first thing a second person does, and it must not depend on the accelerant.

So the check runs in the rules. The household document carries inviteCode and inviteExpiresAt, and a stranger who knows the code writes it back, unchanged, beside arrayUnion of their own uid. The rules compare the two and let the diff touch memberUids alone, by exactly one uid, the caller's. Writing the value back is the only way a rule can take a secret from someone who cannot read the document; the emulator proves the rules see the array after the transform, which is what makes the size check possible at all.

Nothing rate-limits a guess, so the code's entropy is the whole brake: sixteen random bytes, never a short human code, and isInviteCode refuses anything else before a write is tried. The expiry is hygiene — a link pasted into a chat stops working on its own after a week — and re-minting is how a leaked one is retired. Joining does not spend the code, so one link admits a whole family; a member who wants one-at-a-time clears it after each.

#The back-pointer is a list, and index zero is the solo household

users/{uid}.householdIds was always an array, read at index zero. Every account keeps the household bootstrap made, now marked shared: false; a household started from Settings or joined by invite appends after it. Order is the record: the solo one is first because it was made first, and nothing removes an entry, so the position never moves. A stored solo flag would say the same thing twice and could disagree.

shared is a display fact, not a permission. It decides what the built-in shelf is called, whether Work is a shelf at all (below), and whether Settings offers Members; membership stays the only axis the rules know, and a solo household with two members would still work, it would just be oddly named.

#A shared household has no Work shelf

Personal and Work are the mine-versus-job split, and the split belongs to one person: in the household an account is born with, "mine" is the account and "the job" is theirs. A shared household is not one person, so the split has nothing to divide. The first shelf already wears the household's name for that reason; Work drawn beside "Family" was the other half of the same wrong word, a folder for one member's job on a door three people share, and the report was exactly that: it "doesn't make much sense in a shared workspace".

Renaming it was considered and refused — there is no second word a shared household needs by default, and a household that wants one makes a group. Removing the id was refused too: 'work' is a stored word, and the doors' oldest rule is that nothing an id still names goes dark. So work: false in BuiltinNames means "not a shelf here" rather than "gone": Work is never drawn empty and never offered as a place to file or a parent to move under, but it shows, counts and accepts moves for as long as something already sits under it on that surface — content filed before the change or by the assistant, or a subgroup — which is the rule that keeps an empty custom group removable and a full one open, read the other way round. The stored word stays reserved as a group name, because the id can reappear, and the assistant is told the shelf is absent rather than being forbidden the word.

#A household is a scope, and the second one is chrome, not a group

Once an account could belong to two households, the question was where the second one lives on screen. The tempting answer was inside the tree: a "Family" folder on the Tasks door with Family's groups under it. The two-level cap refused it — Family's root groups would be the second level and their subgroups a third, and the cap exists precisely so that breadcrumbs, tree pickers and recursive move validation never have to be built. A group is also a document inside a household, and a household node has no document that could sit in either tree.

So a household is a scope, one level above the doors. It first shipped as a card at the end of each door; that became the bar above every screen (below), and below the bar nothing changed: every hook is scoped by the one household on screen as it always was. That is the whole reason the change was cheap. The rules never see a merged query, no screen holds two households in memory, and no writer needs a target — the location is the target, exactly as it is for a group.

#One household on screen at a time

A merged view — every note from both households on one All notes — was considered and refused. Notes and to-dos could have been concatenated; the pure functions in layer 1 would not have minded. But every row would then need to know its household so its actions could write to the right one; the capture box would need a target chip; people are per household, so @sam in each would be two Sams on one screen; and shopping cannot merge at all — one trip's check-offs would open two sessions and teach two cadence engines. The cost fell on every screen, for a view nobody had asked for. One at a time keeps the app the app.

Routes are literals in thirty files and there is no central URL helper; a prefix that every link had to carry would have touched all of them and broken backName, the tab bar's prefix match and the assistant's matchPath. Mounting the router with the shared household's prefix as its basename makes every one of those read the bare path it always read. The solo household keeps the bare paths, so nothing anyone bookmarked or installed moves. The router has to be keyed by household — it builds its history once — and the shell has to listen for popstate itself, because a Back that crosses households lands outside the mounted base; both are two lines. The alternative, a full location.assign on every switch, is kept in reserve and would work too.

#The first shelf wears the shared household's name

Every household has a Personal shelf and every default write lands there. In the household an account is born with, that is the right word. In a household called Family, shared by three people, "Personal" is wrong twice over. The shelf keeps its id — nothing moves, nothing migrates — and reads as "Family" through one set of names that every renderer takes as an optional last argument. The ids' meaning is untouched: what is built in is still decided without the names. "General" was the other candidate; the household's own name was chosen because it is already what the card on the other door says, so a person sees one word for one place.

#Device keys that name a household carry its id

Four localStorage keys were per device and, without anyone saying so, per household: the seasonal pass's month, the review pass's stamp, the open shop section and the doors' fold state. With two households on one phone the seasonal stamp for one would have silenced the other for a month, and a shop id from one household would have been looked up in the other. Each key now carries the household id. The old keys are left where they are: a stale fold state or a lost "last shop" is a tap, not data.

#Move is the sharing primitive, and a copy is not offered

With two households on one account the ask "share this note with the family" has two possible answers: copy it over, or move it. A copy leaves two documents with no link between them — two truths from the moment either is edited, and nothing to reconcile them on, since no document carries an origin and a back-reference into a household the reader may not be in is a reference the rules cannot follow. The app already refuses two truths everywhere it can (the purchase documents are the truth, the array a rebuildable cache). So the answer is the filing gesture the app already had, pointed past the edge of the tree: hold, pick "Another household", and the thing is over there and not here. Someone who wants a copy retypes it, and gets exactly the unlinked duplicate they asked for.

The move is one batch across both households rather than a create and a delete, for the offline queue's sake: two mutations are strangers to it, and a create refused at sync — a membership lost while the phone was in a shop — would roll back alone while the delete still ran. A batch is refused whole. Creates first in the batch is not what makes it safe; the atomicity is.

#A moved note keeps its bytes where they were

A note's photos live in the bucket under the household that uploaded them, and the receipt under the note is what points at them. Moving the bytes along with the note — download and re-upload from a phone, or a server-side rewrite in a function — was the obvious shape and was refused: the client copy is slow and fails half-done on the network this app is built for, and the function copy makes moving a note depend on the accelerant, which nothing else does.

The receipts cross instead, verbatim. That works because the download URL on a receipt is a token capability: the <img> never asks the rules, so every member of the destination sees every photo the moment the receipt lands. What breaks is small and named. The transcribe function used to rebuild an object path from the ids it was given; it reads the receipt's own path now, after the same membership check it always made, so a moved photo is read from where it is. And a destination member who is not in the source household cannot delete the bytes — the Storage rules gate on the path's household — which leaves an orphaned object nobody can list, the case deleteNotePhoto already tolerated for a failed delete. The mover, still in both, deletes clean. Orphans cost storage and nothing else, and that is the whole price of not moving bytes.

#A shopping item crosses only while it is open

An item on the list is a staging area; a checked-off item is a purchase, and the purchase is the record the cadence engine learns from. Moving an open item is moving a string — it lands as a plain manual item, unlearned, and the destination learns its own rhythm from its own trips. Moving a checked one would be moving a purchase off the trip it happened on, into a household whose trips it was never part of. So the writer refuses it before any write, and the hold is offered on the open list alone.

#On screen it is a workspace; in the schema it stays a household

The word household was right for the people who share a list and wrong for the thing an account switches between: someone with their own scope and a shared one is not in two households, they are in one household with two places to keep things. So the switchable scope is called a workspace everywhere a person reads it — the bar, the picker, Settings, the join screen, the move sheet, the assistant's briefing. The schema, the rules, the collection paths and the code keep household: a rename there would touch every document path and every rule for no one's benefit, and the two words meaning one thing across that line is a cost paid once, in this entry, rather than on every deploy.

#The name at the top, and the picker it opens

The first cut put a card for the other household at the end of each door, which meant a person on Settings could not see the other household at all without walking to Notes first — the complaint that led here. Three placements were drawn for the name: a chip under every title, a bar above every screen, and the household as the title itself with the screen's name as an eyebrow. The bar won for the display — it is genuinely there at all times and sits outside every screen's own layout, at the cost of one line of chrome — and the card picker won for the choosing: the doors' own cards, the one on screen lit, and the way to start a shared one, so the cards the doors used to carry now live in the sheet instead. A switch keeps the tab and drops the rest of the path, since a note's id or a group's means nothing over there. The bar first showed with one workspace too, "Home", as the door to starting a shared one; it does not now. One workspace has nothing to switch to, and a strip naming the only choice is chrome for nobody — the row in Settings is where a shared workspace starts. The strip spans the whole screen, outside the content column, with the control centred on it, so it reads as the app's chrome and not as the first line of whichever screen is beneath.

#A workspace's colour is yours

A colour on a workspace exists so you can tell your workspaces apart at a glance, and that is a fact about you: the same shared workspace can be your second and someone else's first, and the colour that says "the other one" to you says nothing to them. Storing it on the household document would have made it a committee decision over a private convenience. So it lives on the user document, keyed by household, and everyone in a shared workspace picks their own — or picks nothing, and gets a colour from the workspace's place in their list: the solo one emerald, the first shared one blue, and round the other five from there, so two workspaces differ without anyone opening Settings.

The tones are hard-coded — six settings of the four accent tokens, one hue each at the emerald's lightness and chroma — rather than a colour wheel, because every button, chip and count in the app reads those tokens, and a free colour would have to be checked for contrast in both themes on every pick. Six that are known to work is the whole palette.

The neutrals follow the tone too. The emerald theme was "emerald on sage": the ground, the surfaces, the borders and the text greys all carry a faint green cast, which is what makes the white cards read as objects. A tone that swapped only the accent left a blue workspace on a green page, and the report was exactly that — "some of the UI colors are green-tinted even when the accent color is some other color". Plain greys were the other answer and were refused: they would have cost the emerald workspace the warmth it was designed with. So each tone casts the neutrals its own way, by the same amount: every sage value with its hue turned to the tone's at the same OKLCH lightness and chroma, so the tint stays as faint as it was and no contrast moves. Emerald keeps the sage, because the sage is the emerald's cast. Derived by arithmetic and pasted as hex, since the tokens are hex everywhere and a runtime colour function would be the one place the palette was computed rather than written.

#The creator keeps the workspace; a joiner may leave; only the creator removes

Once people could join a workspace the question of the door out had three answers to pin down. The person who started a workspace cannot leave it: it is theirs, and a household with nobody in it can never be written again (delete is refused from the client), so the creator staying is what keeps the document alive. Anyone who joined may leave, taking nothing — the lists, to-dos and notes were the workspace's from the moment they were written. And only the creator may remove someone: the first cut let any member rewrite memberUids ("the de-facto invite"), which was fine while nobody could get in, and wrong the moment they could — a joiner removing the creator, or each other, is not a household. So membership stopped being an ordinary field: a member edits anything else, and memberUids moves only by the invite branch (in), a joiner's own leave (out, themselves) or the creator's removal (out, one other). ownerUid is written at creation and cannot be rewritten; a document from before the field reads its first member, who is the creator because creation always wrote a one-element array.

Leaving is one batch in a deliberate order: the member document first, while the subcollection rule still sees a member; then the household; then the person's own record. Removing is the creator's batch on the household and the member document alone, because the removed person's user document is theirs and nobody else can write to it — so their phone heals its own back-pointer the next time the household refuses it, and the refusal is treated as a fact about membership rather than a failure of the connection. The removed or departed person's assistant thread stays behind, unreadable: a subcollection delete from the client is a read-then-delete loop that can fail half-way, for a few documents nobody can see.

#An undo for another person's edit is an overwrite

"Just changed · Undo" was offered for every version that arrived under an open note, on the reasoning above: text replaced under your eyes should never be text you cannot get returned. That is right for the assistant, which rewrites on your behalf and can be wrong, and for your other phone, which is you. It is wrong for another member of the household: their save is not a change done TO your note, it is their work on a shared one, and the chip offered its erasure with one tap — two people on the same note would each be shown an Undo whose only effect was to throw away what the other had just written, and the note would seesaw between them. Reported exactly so: a note updated because someone else edited it popped an undo.

So the offer follows the write's author. A note stamps updatedBy on every text write, and the assistant writes as the person it acted for, which makes the test one field: the arriving version is this account's, offer the way back; anyone else's, reseed and say nothing. The editor still reseeds either way and unsaved keystrokes still outrank whatever arrives — only the chip is withheld. A change from another person is not marked, in the same posture as lateness: it is simply the note as it now is.

#Merge, not a lock

Two phones with the same note open did not work: each held its own unsaved keystrokes, the editor took an arriving version only while nothing was unsaved, and a note is one plain-text field — so whichever blurred second replaced the whole of what the first had saved, silently. The obvious answer was a lock, and it was the wrong one for this app. A lock is an online thing. The phone that went to sleep mid-edit holds the note until a lease runs out; the phone with no signal never sees the lock and its queued save lands over everything anyway; and blur, the moment the lock would be released, is not a moment a phone reliably has. It would cost "this note is busy" often, to prevent a collision that is rare, and still miss the one case it cannot reach.

So the editor merges. Three texts: the version it last agreed on with the document, what is on screen, and what arrived. The difference between the first and the third is applied to the second — at the word, so two people adding to the same paragraph merge cleanly — and where both changed the same words both versions stand, this phone's first, with the caret kept with the words being typed. A conflict keeps both on purpose: a person can delete the version they do not want, and nobody can un-delete a sentence a merge tidied away. Silence and loss are the failures this design refuses; an odd doubled word is the one it accepts.

Two things make the merge small. The text is written after a short pause in typing, not only at the blur, so what each phone has to bridge is a sentence, not a session; the blur still does the pinning and the capture, because a half-typed to-do line must not become a to-do. And every write says which version it was written from (baseAt), so a version that arrives written from an older one than this phone holds — the other was offline, and its save replaced ours — merges against that older one, which the open screen keeps a few of. The residual is the phone that was offline AND has since closed the note: nothing then holds the ancestor, and the last write stands. A shared history in the document would close it, and would turn plain text into a log; not yet.

Presence rides alongside, and is the part of a lock worth keeping: a badge on the note's top edge saying who else is here and whether they are writing, and a thin caret in the text with their name on it. People who can see each other coordinate by themselves — the reason locks are rarely needed in a household is that a household talks. It gates nothing, so its failure modes are harmless: offline it is silent, a phone that went to sleep drops off after three missed heartbeats, and a heartbeat that does not land is not a change anybody watched vanish.

#The docs site is a script in the repository, not a framework

VitePress or Docusaurus would render these documents in an afternoon, and bring a few hundred packages, a second build system, a theme to override and a CommonMark surface nothing here writes. The input is five files this repository writes itself, in a subset of Markdown small enough to list — and it is listed, at the top of scripts/docs/markdown.mjs. A renderer for exactly that subset is a few hundred lines with no dependency, tested with fixtures like everything else in layer 1, and it fails the build the day a document reaches for syntax it does not know, which is the right day to find out. It is the same posture as the rest of the stack: hand-written CSS rather than a framework, no state library, end-to-end tests as plain scripts. What was given up is a plugin ecosystem the site has no use for, and a renderer that is right about Markdown a document here might one day write; the test that renders every document is what turns that day into a failing build rather than a broken page.

#The household's guide is the capabilities document, verbatim

The obvious docs site has a user guide written for it. This one publishes APP_CAPABILITIES from functions/src/capabilities.ts instead — the prose the assistant is handed so it can answer "how do I…?" — unchanged, under a note saying what it is. Two reasons. It is already the honest description: CLAUDE.md puts that file under the same rule as ARCHITECTURE.md, changed in the commit that changes what a person can see or do, so it cannot quietly go stale the way a guide written once would. And it is the only description that cannot disagree with the assistant, because it is the same bytes. The cost is that its last section addresses the assistant in the second person ("You float over every screen"), which the note explains rather than an edit hides; an edited copy would be a second document to keep true, which is the thing this choice exists to avoid. The same reasoning keeps the home page's opening the README's own rather than a blurb of its own.

#The docs site is its own Hosting site, not a path under the app

planet4.me/docs would need no new site and no DNS. It would also never load on a phone that had opened the app: the service worker answers every navigation on that origin with the app shell (navigateFallback), and a denylist entry for the path is one more thing for the worker's cache to get wrong across an update. Emitting the pages under public/ instead would put them in the precache the worker installs on every phone — several hundred kilobytes of documentation nobody asked their phone to hold. A second Hosting site (smart-list-app-docs, docs.planet4.me) shares nothing with the app: no origin, no worker, no bundle, no auth domain. A broken docs deploy cannot touch the app and the app's worker cannot swallow the docs. It costs one hosting:sites:create and one domain connection, both done once and both by a human, which the README walks through.