#Steps 13–18
#Chore detection and task capture (7c)
captureTasks splits a paragraph on intent rather than punctuation and notices
which items are chores. A chore's rule is proposed by the model and then runs
deterministically: rent is due on the 1st whether or not it was paid on time.
Anything genuinely one-off gets a null rule, because inventing a rhythm for it
means it returns forever.
#The seasonal pass (6)
Two halves, and only one needs a model.
src/lib/seasonal/pass.ts is the deterministic half: tracked staples whose
window opens next month, found locally, same answer every run, offline.
The generative half exists because an item never added is otherwise permanently invisible — sunscreen bought before the app existed. It may suggest only from categories the household has confirmed, enforced both in the prompt and again on the client. That gate is the whole spam-prevention mechanism, and it is stronger than a dismissal list because it works before the first bad suggestion rather than after it.
Every question asks about a purchase, never an object. "Do you use a fireplace?" is unanswerable for anyone with a gas one — they own a fireplace and buy nothing for it, so both answers are wrong and whichever they pick teaches the app something false. "Do you buy firewood?" has one honest answer, and it is the answer the suggestion actually needs. A test enforces the shape; hints carry the cases where a reasonable person would still hesitate.
The question offers yes and no, and only those. An earlier build added a text escape hatch for the case where both are wrong ("it's a gas fireplace"); it went with the other individual chat entry points — see DECISIONS, "One chat surface: the drawer" — so a question neither button fits is left unanswered, and it keeps its slot until it is.
src/lib/seasonal/profile.ts holds the question bank. Its decisive property is
that it is finite: about fifteen household-defining facts, asked one at a
time, ahead of the season each matters for, and then done forever. Item
suggestions never terminate; questions do. Facts the household's own staples
already imply are never asked — tracking dog food answers the pets question.
The question is derived from the profile on every render, not scheduled. An earlier version computed it once a month inside an effect, which meant a reload lost it and the monthly flag stopped it returning: one chance to answer, ever. Only the generative call is scheduled, because only it costs anything.
Both share one "Coming up" slot — a question or items, never both. Each item
appears on the list its category shops at, carried by a destination on the
bank entry: overseed surfaces with the hardware run, pet food with the food
shop. The bank decides, not the model — the model labels an item with a
category, and where a category is bought is a fact about shopping that no
phrasing changes. This also keeps "Add" free of routing: the list you are
looking at is where it goes. The question keeps a single slot on the Grocery
list, the one everybody opens.
Items offer four responses, because the app knows neither your inventory nor your arrangements: add, have it (creates a tracked staple with an implied acquisition, no purchase invented, so next year it comes from the deterministic half), it's a task (an errand the model mistook for a purchase — the name moves to Tasks and is dismissed as an item suggestion, reversibly), or not for me.
#Receipts (7f)
src/lib/receipt/seam.ts is pure and carries the two rules that matter.
Seam detection is positional, never by name. Overlap between photographed
segments is found by matching a run of at least three consecutive lines. Name
dedupe would be wrong in a way that silently loses purchases: two MILK lines
are usually two real purchases. A segment with no detectable seam is appended
whole, erring toward a duplicate the review step catches over a missing line
nobody sees.
The receipt validates itself. ITEMS SOLD is the cleanest signal, the
subtotal the fallback. Price is extracted for this check and then discarded,
never stored. On a mismatch the user re-shoots one segment rather than starting
over.
Names are returned verbatim — GV WHL MLK GAL, not "Great Value Whole Milk" —
because those strings are a per-store dialect meant to be cached and reused.
The capture flow lives at /receipt — behind the + in the middle of the tab
bar, because it
is something you do when you walk in the door, not a property of any one list
(absent when the layer is off). Its first job is filling the list: you
bought these things, you will buy them again, and typing them back in is the
manual work the app exists to remove.
Shooting never waits on the model. Every photo is accepted the instant the shutter closes; its read runs in the background, in parallel with the others, and a part that failed to read retries from its stored image without re-shooting. Review opens once the reads settle.
Each line carries two names: rawName, verbatim register dialect
(GV WHL MLK GAL 007874203972), and name, what a person would write on a
list ("whole milk"). The screen shows the human one — with a local
code-stripping fallback (tidyReceiptName) for functions deployed before the
field existed — while the verbatim string stays underneath as the dialect
cache's key and the seam's evidence. Every line has a one-tap Add: a
matched line goes to its staple's list with the staple attached; a new name
goes to the grocery list, where the ordinary promotion machinery decides
whether it becomes a staple — the receipt never creates staples itself.
Recording purchase history rides along for matched lines (noon of the printed
day, grouped per staple with quantities summed: two MILK lines are two units
of one restock, not a burst of zero-day intervals; same-day duplicates skipped
by default). Matching is local-only — the dialect cache, then the canonical
name — and every recorded line caches its register string, so the next receipt
from that store matches itself. The printed checksum is a footnote, not a
gate: a disagreement is one quiet line offering to add the missing part.
#Pantry photos (7g)
readPantry takes a location tag as an input, not a label: a fridge yields
perishables on short cadences, a garage yields stockpiled durables. It seeds
staples with priors and an empty purchase history — a photo shows what you
have, not what you buy — so the cadence engine still starts from zero. The
prompt is explicitly conservative, since only the front row is visible and
decanted goods are unidentifiable.
The intake lives at /settings/regulars/photo — "Track by photo", a quiet
chip on the Lists home beside the receipt scanner's. Both chips are the gated
part, absent rather than dead when the intelligence layer is off. Pick the
location,
photograph one product or a whole shelf, and every returned item is a proposal
behind a checkbox — already-tracked names are marked and disabled. The route
keeps the domain word while the screen it belongs to is labelled "Regulars".
Its own back target is Lists, not Regulars, because it is reached from the
Lists home rather than from the screen it files into. Confirmed items are created as
estimated staples with the location as the prior's category. profileHints
only ever answer unasked questions, and only with yes — a photo shows
presence, never absence, and never re-opens something the household already
answered. Answered hints land in the settings ledger like any other fact.
Photos are downscaled client-side before upload; nothing is stored.
#The assistant (7h)
Chat appears in exactly one place: the assistant drawer. A floating button
(ChatFab, bottom-right, over every section) opens a bottom sheet holding
your one real thread: households/{hid}/assistantThreads/{uid}/messages,
private to its owner and converging across their phones,
roughly the newest twenty kept and the last ten turns sent to the
model. Earlier builds also placed one-turn ask boxes on the Tasks and shopping
list screens and an escape hatch beside the seasonal question; those
individual entry points are gone — see DECISIONS, "One chat surface: the
drawer" — and the button is absent rather than dead when the layer is off.
It runs assistantChat — Sonnet (claude-sonnet-5) rather than the shared
Opus constant, because a conversation pays for latency in a way one-shot
labelling does not — and may run up to three server-side web searches
(web_search_20260209) per turn, so "what size filter does a Carrier 59SC5
take? make it a to-do" lands the looked-up size in the task's details. The
model answers from what it knows or looks up (standard sizes,
compatibilities); the fact goes in the task's details rather than only the
reply, because the reply scrolls away and the detail is what needs to be in
hand later.
The reply STREAMS into the sheet: the moment a turn is sent, an assistant
bubble appears with three pulsing dots, and the reply's text grows in place
as the function forwards it. What the model is DOING streams beside it as a
log, not a status: a muted line per search (named once its query has
streamed), per note read or asked for and not found, for its actions
arriving and for the read budget closing — lines that stay for the turn
(reduceActivity in src/lib/chat/, idempotent under a replayed chunk, a
named search replacing the unnamed line it started as) rather than one
"Searching the web…" that the first reply character wiped and nothing
brought back. Once the reply lands the same lines sit behind a disclosure
under it, with a one-line summary — "2 searches · 1 note read", "cut
short". The seam is assistantChatStream, which answers {kind:'answer'}
or {kind:'failure', code} — total like every other seam, but saying WHICH
failure (AssistantFailureCode in src/lib/chat/failure.ts: rate limited,
declined, unfinished, unreachable, not signed in, unreadable, empty), from
the function's own error chunk when it got that far and the thrown code
when it did not. What streams is a preview only: the turn's persisted reply
is the callable's final text, and a failed turn writes nothing.
The log follows the newest words until you look away. The sheet is a
column — the log scrolling, the composer fixed under it — and the dots, the
activity lines and the streaming bubble all live inside the log, so whatever
grows it grows it below the fold. It used to pin to the bottom only when a
message landed or the streamed text changed length, and everything else that
changes the log's height while a turn is in flight — an activity line, the
dots giving way to those lines, the summary under a finished reply, a failure
notice, the photo picker folding open and shrinking the log frame by frame,
the keyboard shrinking the sheet — left the newest thing out of sight. A ResizeObserver on the log's content
(.chat-log-inner) and on the log itself now pins on every change of size,
and a layout effect pins before paint on every change of the thread's shape,
while one ref says whether to: stuck, set from the log's own scroll events
by atEnd (src/lib/chat/pin.ts, a few pixels of slack), true while the
reader is at the end, false the moment they scroll up to read something
older, true again when they come back or send. scrollTop on the log
itself, never scrollIntoView, which would drag the page behind the mask;
and the Popup's afterShow still does the first pin, because the sheet
animates in with no height.
Its action schema is adds, one annotation, and one rewrite — add_item
(with an explicit shopping list), add_task (with an explicit to-do list,
home unless they clearly mean their job), add_note (with a notes area,
personal unless they clearly mean their job), annotate_task, and
edit_note — planned by planAssistantActions in src/lib/chat/, which
decides what each returned intent means before anything is written; the hook
only performs the writes. The destructive verbs are otherwise deliberately
absent: the drawer floats over screens it cannot see, and a remove on a guess
is the one mistake that loses data. So a wrong guess can put something on the
wrong shelf, one tap from fixed, but never take a row away. Adds get no undo bar, as everywhere. A failed turn
writes nothing to the thread; on the phone that asked, its question wears
the failure's own sentence and a Retry underneath it, this phone's news
only and never a turn in the conversation — filed where it belongs rather
than as one line at the bottom that the next send erased, which left a
thread of questions with no answers and no record of why. Only an
"unreachable" failure clears itself when the network returns; a decline or
an empty reply was never about the network. A question nothing answered is
also left OUT of the history the next turn sends (historyWindow), because
sent back it put the same question in front of the model twice — once as
history it never answered, once live — and a reply of ":" was once what
came of that. See DECISIONS, "A thread in the drawer, one turn in the ask boxes",
for why the outcome-not-transcript rule bends for this thread.
annotate_task is how information reaches a to-do that already exists.
The context snapshot lists open to-dos with their existing details in
parentheses; the planner matches the echoed title by canonical name, narrowed
to the action's to-do list when it names one, so a same-titled to-do on the
other list is out of reach. The note is appended to the details already
there, never a replacement: the model saw those details as one line in a
snapshot and cannot be trusted to re-send them, and "add the vet's number"
must not eat the filter size somebody typed by hand. An add_task carrying a
note whose title matches an open to-do is planned as the same annotation,
whatever kind it came back labelled — "add the vet's number to the flea
treatment task" lands on the existing row, never beside it as a duplicate. A
title matching no open to-do becomes its own to-do carrying the note, because
wrong shelf beats vanished; an annotation with nothing to attach is dropped,
because performing it could only mean touching details it was not given.
The assistant reaches the two built-in groups only. Its action schema
says home and work for to-dos and personal and work for notes — the
same shelf under both of its stored names, which is why home on an
add_note is read as personal rather than refused; the system prompt tells
it the home list is shown to the household as "Personal", and the context
labels the lists by their display names. Custom groups stay out of the snapshot deliberately: the
schema cannot target them, and a to-do the model can see but never reach only
invites annotations that go nowhere.
edit_note rewrites the note on screen, and can reach nothing else. The
schema has no field naming a note: the planner supplies the id from the route,
so a model that invents a target has nowhere to put it. It is dropped unless a
note is open AND the model was shown that note whole — NOTE_CONTEXT_CAP in
useAssistant is both the snapshot's slice and the guard's yardstick, so a
rewrite of a note the model saw only the head of cannot delete the tail. What
comes back replaces the note: name is the new first line, note the new
body, through the same title/body seam typing uses. The blank-name guard every
action passes is what keeps an edit from emptying a note. On screen the change
lands through NoteScreen's reseed (below), which also offers the one tap
back. See DECISIONS, "The assistant may rewrite the note in front of you, and
nothing else".
The assistant is told what the app is. Its system prompt ends with
APP_CAPABILITIES (functions/src/capabilities.ts): a plain-prose,
present-tense description of every screen, the note grammar, groups, photos
and their read text, the calendar, people, tags, questions, Settings, offline
behaviour, and the assistant's own limits — so "how do I make a question in a
note?" is answered from a document rather than a guess, and the prompt tells
it to say so when the document does not cover the question. It is a claim
made to the household, held honest two ways: prompts.test.ts asserts the
note markers appear in it in the blank note's own words, and CLAUDE.md puts
the file under the same same-commit rule as this one. The whole system
prompt is sent as one cached block (cacheSystem in askStream,
cache_control: ephemeral), and each pass logs cache_read_input_tokens
so a silent invalidator shows up as a read of zero.
The context snapshot carries note titles only — except the note on screen.
The FAB is rendered inside the router (AssistantFab in App.tsx), so it
knows when the current route is an open note; while one is, that note's full
text (capped at 8000 chars with the same cut marker, inside the callable's own
24000-char context cap, CONTEXT_CHARS)
joins the snapshot, and the system prompt tells the model "this note" means
that one. So "summarize this note" and "make to-dos out of this" work while
reading it, and no note's body is ever sent from anywhere else — everywhere
else the assistant sees titles alone — the titles, the to-dos and the
questions all from inside the household's to-do-links look-back, the same
cut the function makes for read_note, so the snapshot never names a note
the tool would refuse and the tool never hands over a note the snapshot
withheld — and reads the rest: the request
names the household, and when a title tells the model which note holds the
answer, it reads that note through the read_note tool (see The proxy above
— fetched server-side behind the same membership check, folded-title
equality, at most eight notes a turn, 8000 chars each) — without asking
first, several in one batch when several titles fit the question, because
the fullest answer is the point and "want me to check that note?" spends a
whole turn on a yes the question already gave. While it reads, the
wait line names the note; the drawer's busy states say what is actually
happening — Reading your note "Boiler service"…, Searching the web for
"…"… — rather than one generic word. Notes read this way are readable,
never editable: edit_note still reaches only the note on screen. See
DECISIONS, "The assistant reads a note by asking for it". Photos ride only when
picked (below) — and the reply can show one back: read_note appends
a numbered listing of the note's photos, each with the head of its transcript
where one exists, and the open note's photos ride the snapshot the same way;
a second household-gated tool, show_photo({title, photo}), resolves the
title and number server-side to the stored photo (the same folded-title match,
the note screen's own addedAt order), refuses in words the model can read
when there is no such note or number, and puts {noteId, photoId, url} on the
answer's photos — at most three, each once. The drawer persists them on the
assistant's message and renders them under its bubble as thumbnails, each a
link into the note that holds it (the viewer lives there), closing the sheet
on the way. The model is told it can show only what a listing named and can
neither make nor find images. Nothing is staged by default, because most questions about a note
are about its text and unasked images are tokens and seconds nobody chose to
spend. Two kinds share one selection. The note's photos: AssistantFab
holds the same photo subscription the note screen holds (one SDK listener
serves both), and the request names the staged ones — ids only, in pick
order, the bytes fetched by the function after the same membership check
transcription makes — so "what's on this receipt? add what we're out of"
works while looking at the note that holds the receipt. Photos from the
phone: the strip's first tile opens the camera or the library (a plain
<input type="file" accept="image/*" multiple>, no capture, so the system
sheet offers both); each file goes through toJpegBase64 in
src/components/photo.ts — the receipt intake's own encoding, 1568 px and
EXIF-stripped by the canvas — and rides the request inline as images,
never stored anywhere, held in the hook only until the turn that carries it
lands. The function keeps at most three images in all, the note's first, and
tells the model under the snapshot which came from where. The staging
surface lives on the thread useAssistant returns (attachable /
selected / togglePhoto / addDevicePhoto), with the pick arithmetic —
the cap, the order-as-numbering, a fourth pick displacing the oldest, a
deleted photo pruned rather than sent as a ghost id, and the two kinds told
apart by the device: id prefix — as fixtures in src/lib/chat/selection.ts.
Leaving a note drops that note's picks and keeps the phone's (keepDevicePicks);
a delivered turn consumes exactly its own picks, so a failed turn keeps them
for the retry and a pick made mid-generation survives for the next send. An
unstaged phone photo is let go entirely — it was only ever here for one turn.
No note photo is ever named from any other screen.
The snapshot carries the Questions sections too — answers included.
Questions live as documents (households/{hid}/questions), and the document
is the only place the whole record exists: a long answer never reaches the
source note's stamped line, so no amount of read_note finds it. The drawer
is therefore handed the same questions array the app already subscribes
(plus people, to name who a question is for), and questionSnapshotLines
(src/lib/questions/snapshot.ts, fixture-tested) folds them into two lines:
open questions (newest asked first, capped at 12, "text" (for Name)) and
answered ones (newest answered first, capped at 8, the answer riding sliced
at 150 chars). The caps are the point — the snapshot is working context, the
Questions screen's shelf is the memory — and the system prompt tells the
model what the sections are, that an (answered …) stamp met while reading
a note means the same thing, and that it can neither ask nor answer a
question: no action does either.
Transient bars are one component. Toast (src/components/) carries the
card, the edge, the enter and exit motion, and the countdown; UndoBar and
DayOffer are thin wrappers over it, keeping their own reasoning next to the
thing it explains. The edges are claimed and the reasons written into the CSS:
bottom is the undo bar's, where a thumb already is on a list; top is
everything else's, clear of the screen header. A screen shows one bar at a
time, an error before an offer. What earns one is narrow — an event, at most
one action, safe to miss — and DECISIONS, "What earns a toast", records what
stays inline instead.
The exit is driven by a timer in Toast, never by animationend. The bar
arrives over --dur-open and leaves over --dur-close, and the timer is
CLOSE_MS from src/components/motion.ts, the vocabulary's JS half, which
MUST match the tokens. Under prefers-reduced-motion the durations are
zero, so animationend would never fire — an accessibility setting would
have stranded the bar on screen permanently — and the timer goes to zero
with them. Reduced motion therefore removes the movement and keeps the
leaving.
Motion is one vocabulary. tokens.css names it: --dur-fast (120ms — a
caret turning, a press wash fading after the finger lifts), --dur-open
(200ms) and --dur-close (150ms) for anything that folds, with --ease-out
on the way in and --ease-in on the way out. Closing is faster than opening
on purpose; DECISIONS, "A fold closes faster than it opens", says why. Under
prefers-reduced-motion the three durations go to zero at the root, so every
transition keeps its end state and drops the travel — one rule, not one per
component. The seven <details> shelves (the six .shelf sections and the
chat's "What it did") are Shelf (src/components/): still a <details>
for its semantics and the suites' selectors, but the summary's click is
React's and the body is a Fold, because the native disclosure snaps and
its one animation (::details-content with interpolate-size) is Chrome's
alone — on an iPhone every shelf snapped beside a folding door. Shelf
keeps the element's open through the leaving so the rows can fold out
before they are hidden. The .shelf summary carries the chevron the flex
display had cost it, turned by its own aria-expanded in the shop
sections' idiom. The fold heads (.group-head, .door-head) take their wash
the instant they are pressed and fade it only on release.
Fold (src/components/) is the one way a React-rendered body opens and
shuts. It wraps what used to be expanded ? <ul/> : null at the shop
groups (StoreGroup), the group door (GroupCards) and the group tree's
children (GroupTree), and keeps that pattern's one virtue: closed and
settled, it renders nothing, so a folded shop with sixty rows costs nothing.
Open, it renders the body in a grid whose single track the CSS animates
from 0fr to 1fr — no measured heights, no JS on open. Three rules,
argued in DECISIONS, "A fold keeps nothing mounted and moves nothing on
first paint": a body open on the render that mounts Fold paints at rest
and only a later flip of open earns the enter motion; unmounting after a
close waits on a timer matched to --dur-close (motion.ts holds
OPEN_MS/CLOSE_MS, which MUST match the tokens, and reducedMotion()),
zero under reduced motion, never transitionend;
and the state that starts the leaving is derived during render, since an
effect would be a frame late. data-motion is on the element only while
it moves — it is what starts an enter from nothing and clips the body only
then, so at rest a focus ring inside is never cut. The tree's caret is now
one chevron turned, the shop sections' idiom, where it used to swap two
icons. The same primitive folds a trip's detail (TripsScreen), the
explanation under the offline and can't-sync chips (App), and the chat's
photo picker (ChatDrawer, where onSettled pins the log once more at the
picker's final height). Every control that folds something wears the one
caret, .group-caret, turned by its own aria-expanded; the trip row and
the chips gained theirs with their motion. The capture links' box
(ActionLine), the search bar under a tool header (ToolHeader), the
Regulars explainer (RegularsNote) and the note shelf's facts and photos
strips (NoteScreen) fold the same way; for the strips the editor keeps
pace, transitioning its own computed height at the fold's timings, so the
two move as one (DECISIONS, "One motion, the fold"). Every collapsible in
the app now moves, or deliberately does not; antd's
own sheets and drawers keep the library's motion. Everything else that
moves — a card pressed, a box taking focus, a checkbox filling, the
switch, a meter's fill, the toast — spends the same tokens: --dur-fast
for a press or a focus, --dur-open/--dur-close for an arrival or a
leaving. The one duration outside the vocabulary is the row flash, an
event fading over 1.8s, and the chat dots' ambient pulse, which reduced
motion stops outright.
When a save introduces a day the document has not committed to, DayOffer
appears at the top of the screen — clear of the header, since a bar over Back
and the title takes away the way out at the moment somebody has just stopped
typing — naming the day with one action. It expires rather than waiting to be
dismissed and remembers nothing: ignoring it is the answer, and it is the
common one. Accepting appends commitLabel through the same commit path typing
uses, so the day is pinned, captured and written exactly as if it had been
typed by hand — at the end of a note's body, or the end of a to-do's title,
where a to-do already wears its day. Its countdown is useVisibleCountdown,
shared with the undo bar, so the "an undo that cannot be seen is not an undo"
lesson is not re-derived for a second transient bar.
In the sheet this is a photo-stack button left of Send, live on every screen
— the phone's camera and library are always behind it — carrying the count:
the number staged once anything is, else the open note's photo total. The
count is load-bearing rather than decorative: the sheet covers the note, so
without it nobody can see there are photos to offer at all. It opens a strip
inside the sheet, never a second sheet — modal over modal gives two dismiss
layers and an ambiguous back gesture — whose first tile, always, is "From
device" (a dashed outline, so it reads as a door rather than a picture), then
any phone photos already taken this turn, then the note's; the strip is the
note page's own thumbnail row, down to the token. A file the phone cannot
decode is said so in one quiet line above the composer, and the rest still
land. A picked thumbnail wears an accent ring AND its send-order number,
so the state never rests on colour alone, and that number is what makes the
cap legible without a "max 3" label anywhere. With the strip closed over
them, the staged photos ride as small removable chips above the input;
showing both at once would be the same three photos twice in one sheet. The
one thing the eye gets free and a screen reader does not is the displacement
— a fourth pick releasing the earliest — so that alone is announced through a
polite live region (.sr-only, the app's first), while aria-pressed
carries the ordinary attach and detach. /notes/personal and /notes/work
match the same route pattern, but a Firestore auto-id can never be either
word, so the lookup finds nothing there and nothing extra is sent.
The sheet and the keyboard. The popup is fixed to the bottom of the
LAYOUT viewport, and on iOS that viewport does not shrink when the keyboard
comes up — only the visual one does, and dvh tracks the browser's bars,
not the keys — so the composer sat exactly under the keyboard the first
time the input took focus. While the sheet is open the drawer reads
window.visualViewport (height and offset against innerHeight) and
writes the covered height to --keyboard-h on the root; .chat-sheet is
lifted by that much and capped at what is left above the keys, and the
log's ResizeObserver re-pins the newest message as it shrinks. Where the
browser resizes the layout viewport instead, the difference is zero and
nothing moves.
#The review pass and Tidy up (7d)
The pass proposes; it never decides. It writes only to
households/{hid}/suggestions, never to a staple or cadence directly. So a
cadence that moved can be traced to a proposal someone accepted, the pass failing
breaks nothing, accept-rate per type shows which prompts earn their keep, and a
model that answers differently across runs is merely inconsistent rather than an
unreproducible bug in the data.
It runs weekly and only past a purchase threshold, because against a thin dataset it will confidently describe patterns that do not exist.
src/lib/suggestions/ holds the deterministic gate every proposal passes before
anyone sees it: a proposal naming a staple that no longer exists is dropped
silently; duplicates against pending and within a batch are removed; dismissals
— recorded on the household document's dismissals array as
{type, targets, at, permanent} and read back with the household — are
suppressed for ninety days when temporal, permanently for merges and
splits, since "milk and almond milk are different things" does not become
untrue in ninety days. The reader keeps type, targets and at; permanence
is re-derived from the type, so the stored flag is a courtesy to anyone
inspecting the document. The queue is capped at five by dropping the least
valuable, so a split arriving late displaces a cadence nudge rather than queuing
behind it.
Most types never reach the queue. "Still buying this?" is answered inline where the staple already is; co-occurrence and prior drift are observations with nothing to accept and are not surfaced at all.
The Tidy up card is last on the home screen and deliberately unbadged. Every suggestion is the app asking for maintenance on its own data model, and nagging about that inverts the relationship. If it is never opened, everything keeps working.