#Modules
#src/lib/types.ts
Every domain type as plain data. Timestamps are Millis — epoch milliseconds
from the device clock, never a Firestore sentinel. Contains the persisted
document shapes (StapleDoc, TaskDoc, NoteDoc, ListItemDoc, SessionDoc,
StoreDoc, HouseholdDoc, UserDoc, PurchaseDoc), the value objects they are
built from (PurchaseEntry, Season, Recurrence, LatLng), and the
projections the pure engine consumes (StapleSnapshot, CadenceResult,
AutoAddCandidate).
#src/lib/time.ts
Local-time date math: DAY_MS, daysBetween, intervalDays (rounded to whole
days, minimum 1), addDays, monthOf, startOfLocalDay, dayKey, and
addMonthsClamped (Jan 31 + 1 month lands on Feb 28, not Mar 3).
#src/lib/cadence/ — the recurrence engine
Client-side arithmetic over at most twenty numbers per staple. computeCadence
is the entry point; cadenceFor adapts a StapleSnapshot to it.
| Module | Holds |
|---|---|
stats | median, mad, regularity |
season | month-range predicates that wrap the year, and crossesSeasonBoundary |
intervals | gaps between purchases, seasonal exclusion, the six-interval window, the per-unit series |
confidence | the 0.20 / 0.45 regularity bands |
predict | prior blending, per-unit horizon, computeCadence, cadenceFor |
leadTime | trip cadence from store visits, and the two-day fallback |
autoAdd | which staples earn a place on a list today |
dormancy | when a staple has gone quiet, with the seasonal exemption |
snooze | snooze horizon and the six-hour duplicate-check-off window |
A CadenceResult carries n, medianDays, madDays, regularity,
effectiveIntervalDays, nextDueAt, confidence, and inSeason. medianDays
and madDays describe observed real-world gaps; regularity describes the
per-unit series the prediction is built from, so it is deliberately not
madDays / medianDays.
#src/lib/normalize/
canonicalize reduces a typed string to a resolution key: lowercase,
depunctuate, drop quantity and filler tokens, drop a trailing "for …" clause, and
singularize conservatively. Percentage variants such as 2% survive as variant
markers, so 2% milk and milk stay distinct — whether they are the same staple
depends on what else the household buys and is not decidable from the strings.
Fuzzy matching is src/lib/resolve/'s job, layered on top at add time; the
normalizers themselves stay deterministic string surgery.
#src/lib/sessions/
boundaries holds the 75-minute inactivity threshold, the strict comparison that
keeps a session open at exactly 75:00, and the backdating of closedAt to the
last activity. (The checked-item listener's window used to live here as a
constant; it is the household's to set now — see src/lib/lookBack/.) storeInference holds haversine distance and inferStore, which
returns a match, an explicit ambiguity, a nearest (within two kilometres —
close enough to pre-select as an answer to correct, never to apply on its own),
or nothing — a 150 m match radius with an 80 m collision radius, so two stores
in one shopping centre produce a question rather than a guess.
#src/lib/lookBack/
How far back the app reads, as arithmetic. LookBack (in types.ts) is what
the household document stores: days, one number for everything;
overrides, the switch that puts the surfaces' own numbers in force at all;
and surfaces, a per-surface number or null for "same as everything", over
five surfaces — trips, calendar, people, questions, todos.
readLookBack is the total reader (the rules let any member write any shape
here, so a string, a negative or a missing map all read as something the app
can run on; a document from before the switch reads as switched on exactly
when a surface holds a number, so nobody's window moves on upgrade);
clampDays rounds and clamps to whole days in [1, 365];
lookBackDays / lookBackStart answer one surface; lookBackWindows answers
all five at once as {days, since} pairs for a now; parseDays is the
Settings row's parse (blank, digits, or invalid — not clamped, the data layer
clamps). notesSince / tasksSince / questionsSince are the filters every
seam applies, inclusive at the boundary, each by the one stamp a document ages
by — a note by updatedAt, a to-do by createdAt (it has no other), a
question by askedAt — and each hands the SAME array back for a null window,
so a memo keyed on the result holds. Ninety days is the default and it is
fixed, not "unlimited": a household's rhythm fits in three months, and it is
what the checked-item listener can carry without reading the household's
whole shopping life at boot.
Two rules every consumer follows. A document is never hidden by the
window — notes stay on their shelves, to-dos on their lists, each opens as
it did; only what is DERIVED from them forgets. And the window is fixed per
household snapshot (useLookBackWindows reads Date.now() inside its
memo), the checked listener's own rule made everyone's: a boundary that slid
with the clock would re-run every derivation on every render for a change
nobody could see.
#src/lib/recurrence/ and src/lib/promotion/
nextOccurrenceAt walks a local calendar grid anchored to the rule, never to
when a task was completed. promotionCandidates groups manual adds by canonical
name and offers to track anything added three times — within the household's
trips look-back, which is what the checked-item listener carries (ninety days
unless Settings says otherwise; src/lib/lookBack/), plus whatever is open
now.
#src/lib/notes/
What the Notes screen shows, as arithmetic: the archived split, keyword
matching, the three sort orders, the one-line preview under each title, and
noteText / splitNoteText — the seam that turns a stored title and body into
one editable surface and back. noteText stands the title apart with one
blank line; splitNoteText absorbs that one line, so the separator is never
stored and every blank line past it belongs to the writer. Matching is substring over folded
text (lowercased, combining marks stripped, whitespace collapsed) and every
whitespace-separated token must appear somewhere, though not in the same field.
It deliberately does not reuse canonicalize or trigramSimilarity. Both are
right for their own jobs and wrong for prose: canonicalize strips quantity
words and singularizes, so a note holding "300g strong white flour" would stop
being findable by "300g"; trigramSimilarity ranks short entity names, and over
a multi-paragraph body the trigram set dilutes, so a note gets less findable
the more is written in it.
detectFacts (facts.ts) scans prose — note bodies and to-do detail lines —
for phone numbers, emails, URLs and street addresses, conservatively and in a
fixed claim order (email, url, phone, address; a span claimed once is dead to
the rest), returning every deduplicated fact in the writer's order, each
with the tel: / mailto: / https:// / maps href a chip navigates to;
factChips is the first six (MAX_FACTS), the strip's ceiling, and the
note shelf's closed line counts the whole.
Tuned to miss before it lies — see DECISIONS, "A false chip is worse than a
missed one".
captureNoteTodos (todos.ts) scans a note's body lines — never its title
line — for lines opening with a to-do marker (todo / to-do in any case
behind at most a list bullet, then a colon, a spaced dash, or whitespace; the
two-word to do only with its colon), returning the titles to create and the
text with each captured line pinned (now a to-do). The pin in the text is
the idempotency, exactly as the pinned dates carry theirs — see DECISIONS, "A
to-do typed in a note is captured, and its line is pinned". A note's area IS
the to-do list its captures land on — one value, no translation, since
canonicalGroupId collapsed the one pair that used to need it.
todoChips walks the pinned lines back the other way: each
line whose to-do is still OPEN on the note's own list yields a chip carrying
the task's id and real title — matched by exact title first (the line is the
very text the task was created from; canonicalize strips numbers, so
"pack box 2" and "pack box 3" are canonical twins), canonical equality as the
drift net. A completed, renamed-away or deleted task yields no chip.
stampCompletedTodos shares that exact resolution (one resolveTodo, so the
chip and the stamp can never disagree): completing a to-do rewrites each
line's pin to "(done Sep 3, 2026, 2:41 PM)" — locale-free, device clock — and
capture skips any "(done …)" parenthetical, so a completed to-do can never be
resurrected by the note's next save. todoSources reads the same lines
backwards — task id → the note(s) whose pinned lines resolve to it, archived
notes included — for the task row's mark and its way-back chip.
completionStamp.ts and answeredStamp.ts are the two stamp SHAPES, each a
handful of lines importing nothing (the second takes only Span as a type).
They exist as their own modules because several scanners must agree about a
shape and none may import another: the to-do capture and the question
capture both refuse to re-capture a stamped line, and detectDates refuses
to chip the date inside one — a completion and an answer are records of the
past, and these scanners read a note for what is still ahead of it. Both are
matched loosely (any (done …) / (answered …) parenthetical), so a
hand-typed one earns every refusal too. The WRITERS live with the features
that need the pins' day format — stampCompletedTodos in todos.ts,
stampAnsweredQuestion in questions/ask.ts — which is what keeps these
two from ever depending on the date layer that depends on them. Fixtures
bind each pair: a test stamps a line and asserts every scanner leaves it
alone.
#src/lib/groups/
The shared group namespace as arithmetic: groupSlug (a name folded to one
comparable form, exactly as search folds — no longer an identity, only how
sameGroupName tells two names apart, and kept in preference to
foldForSearch because the two induce different equivalence classes and it is
the slug's that says "School-run" and "School run" are one name),
canonicalGroupId (the one value a shelf is stored under — 'home' folds onto
'personal', applied inside readTask and readNote so nothing above layer 2
meets both words; see DECISIONS, "One shelf, one stored word"), groupName
(built-ins are Personal and Work on both surfaces, on either spelling — see
"The first shelf wears a shared household's name" under Households for what
a shared household calls the first and does with the second; a custom
name comes from its document, and a group with no document is UNNAMED_GROUP
— "Untitled group" — because an opaque id is not a name and showing one would
put a key on screen), rejectGroupName (blank, a reserved NAME — Personal or
Work, not Home, which is a reserved id only — a duplicate name, or a name
alive on the other door, which is refused and pointed at the chip that joins
it deliberately), rejectGroupRename (the same, minus the id-shape rules,
which belonged to the birth of a slug id; excludes the group itself, scans
every door, and accepts a ghost id whose document is gone),
taskGroupCards / noteGroupCards (ONE SCREEN's cards, flat and by name:
the DOOR (level: null) shows the built-ins first — Personal then Work,
Work only where it is a shelf (builtinShelves) or holds something on this
surface — then the top-level customs, and groups only, never content;
inside a top-level group, its subgroups; inside a subgroup, nothing, by the
ceiling. Declared on the surface OR implied by content, so nothing an id
still names can become unreachable, and a group brings its PARENT onto the
door with it. Counts are ROLLUPS — the group plus everything filed under
it; only a custom empty through its whole subtree and holding no children
is removable, and archived notes hold a notes group open), canHoldGroups
(whether a group's screen may hold groups at all — the writers' half of the
ceiling, asked before a screen renders a group section or a New group box;
false for a group whose document is not in hand, a ghost or one not yet
loaded, since nothing could be filed under it anyway),
and
notesAreaPath / tasksGroupPath (where a group lives).
tree.ts beside it is nesting as arithmetic: groupParent, groupAncestors,
groupDepth (0 or 1, never more), fileTargetTree (every place a to-do or
note can be filed, for the row's move sheet — Personal, Work, then the
customs, each with its own children under it, and a child whose parent is not
itself a destination lifted to the top rather than lost),
groupSubtree (self-inclusive, because
every caller wants the folder counted with what it holds — and, at two
levels, just the group and its direct children), groupParentChoices (the
built-ins and the top-level customs, flat), and rejectGroupParent — self, cycle, missing parent, a
destination that is itself a subgroup, and a group that holds groups trying
to leave the top. All flat checks, which is what MAX_GROUP_DEPTH (2) buys.
Two levels, and the ceiling is in the data model. groupParent refuses
to RENDER a third level as firmly as rejectGroupParent refuses to write
one: a parent that is itself a subgroup, a dangling parent, or a cycle all
read as null, so however a document is
mangled every group stays reachable at depth 0 or 1. That is what makes
breadcrumbs, tree pickers and recursive move validation not merely avoided
but inexpressible — every list of groups in the app is flat.
The cap and the acyclicity are product rules, not boundaries. Firestore
rules cannot traverse a parent chain and this app's are membership-only by
design, so rejectGroupParent runs on one device with one snapshot and two
phones can write a cycle between them regardless. Every reader here is
therefore total against data that breaks the rules: a chain that returns to
where it started is not a chain (groupParent answers null, so BOTH ends of a
cycle render as roots — visible and fixable — rather than as children of each
other, which would make both vanish), a parent naming a group that does not
exist is no parent, and every walk is bounded by the group count.
#src/lib/groups/search.ts
The level search as arithmetic. levelScope says what a screen's level
covers — a group and everything under it (groupSubtree), or null for the
door, which covers everything on its surface, ghost ids included — and
inLevel asks whether a document's group is inside it. searchGroupCards
narrows a level's cards to the names containing the query, folded as search
folds, and from the door reaches one level further down, each found
subgroup saying where it lives (In Renovation) in place of a hint; the
cards come from the surface's own builder, so a found card carries its own
level's count, removable flag and hold sheet. An empty query is no search
and hands the level's cards back untouched. matchesQuery (in
src/lib/notes/) is the one text rule both surfaces match documents with.
#src/lib/people/
@ mentions found in prose, under the chip scanners' charter. detectPeople
(detect.ts) claims one token per mention — letters, digits, hyphen,
underscore, opening with @ and ending at whitespace or punctuation, so
@sam. is Sam and a full stop — and never guesses at a bare capitalised
word. The fact shapes go first as refusals: [email protected] is an email and
the @sam inside a pasted URL chips nobody. There is no orchestrator sharing
claims across scanners, so the agreement with detectFacts is bound by
fixtures feeding both scanners the same strings, the completionStamp way. The
known people arrive as an argument (calendarEntries' pattern) and stay a
pure input; resolution folds the handle against every name and alias, greedy
only where that is safe — @sam chen claims two tokens only when somebody
already answers to that two-word fold. An unresolved mention comes back with
personId: null, the unborn state the note's save pass turns into a person.
personAppearances and peopleNamedIn (appearances.ts) take the
household's people-and-tags look-back as since: a note last written, a
to-do made or a question asked before it is not read, so a person's page and
the directory's group filter forget the same texts. The people themselves
are documents and are never windowed — the roster, the merge and every
resolution see everyone. coMentioned and sharedFacts (related.ts)
are the page's two crossings, over the same live texts and the same
window: everyone else the texts naming the person name, most often first
(a question counts when it is theirs, and an addressee naming them counts
back), and the facts in those texts — by href, detectFacts' own
identity — that texts NOT naming the person also carry, each with those
texts, newest first. A fact only the person's own texts carry is left out:
it leads nowhere new. me.ts is the account's link to a person
(personForUid) and the readings over it (mentionsPerson,
questionFor, youLine), see the Firestore section on people.
fold.ts holds the handle fold — lowercased, -/_ read as spaces,
whitespace collapsed, and what the handle grammar cannot carry gone:
accents stripped, apostrophes and punctuation dropped, the Latin letters
that decompose to nothing spelled out (Sam O'Brien, José and Søren fold to
sam obrien, jose, soren, the folds of the tokens the @ menu types
for them; letters of any script survive) — which is WITHHELD from the public contract for
groupSlug's reason: it is how prose resolves against what exists, never a
key anything may derive identity from. The barrel exports the operations
built on it instead: nameFromHandle (what a bare handle births),
insertHandle (the typed token the @ menu inserts — a word resolved back
through the fold, never an id: "Sam Chen" → sam_chen), handleFor (that
token whole, with the nickname glued on in parentheses when there is one —
sam_chen_(Sammy) — so the text says both what they are called and what
the household calls them, and either half resolves), and renamedPerson / mergedAliases, the
alias arithmetic that lets a rename or a merge cost no prose because the
mention in the text is the link.
#src/lib/questions/
The ask marker, through the to-do capture's machinery (ask.ts).
captureNoteQuestions scans a note's body lines — never its title — for
lines opening ask @sam: … / ask @sam … (the @ makes the colon
optional) or bare ask: … (colon required — "Ask the plumber about the
boiler" is prose), returning the questions to create, each carrying the
handle after ask @ or null for a question addressed to nobody, and the
text with each captured line pinned (now a question). The pin is the
idempotency, the to-dos' own idiom; capture refuses completion-stamped
lines, so a stamped to-do can never double as a question. Tags stay in the
captured text — they belong to the line and are derived, never stored.
questionChips walks the pinned lines back: each line whose question is
still OPEN from this note — unanswered and not archived (sourceNoteId
scopes it; a question knows its note, so no list-matching) — earns a chip
with the question's id and real text. One private resolveQuestion — exact text, then a
whitespace-collapsed lowercase fold (deliberately not canonicalize, which
is grocery-tuned) — is shared by the chips and the answered stamp,
resolveTodo's precedent, so the two can never disagree about which
question a line means. stampAnsweredQuestion writes the answer back over
the pin in the shape ../notes/answeredStamp reads: a short answer rides
the line ((answered Sep 3, 2026: two years from install)), while one over
ANSWER_INLINE_MAX, or carrying parentheses or a newline the one-line stamp
cannot hold, leaves the date alone in the line and the body on the document.
Distinct from seasonal/profile's household-profile
questions ("do you have a fireplace"), which interview the household about
its purchases; these are questions the household asks its own people.
tags.ts is the crossing axis. detectTags reads #renovation off a
question's own text on every render — the handle's grammar with a #, one
token, deduped by fold in the writer's order, and refusing a purely numeric
token so "issue #4" tags nothing. Nothing is stored, so there is no
array to drift from the words and editing the question edits its tags — the
pinned dates' seam. tagGroup(tag, groups) answers the group a tag names,
built-ins included, or null: a LOOKUP over the live documents through
sameGroupName, never a derivation of an id from a name, which is the
distinction groupSlug's withholding protects. A tag that resolves
navigates to that group's notes; one that names nothing is a concept that
groups questions and filters /questions?tag=, with no document and nothing
to clean up — and the day a group of that name is made, every question
carrying the tag starts pointing at it with nothing rewritten. sameTag is
exported for the same reason sameGroupName is: an index or a filter
comparing tags any other way would invent a second equivalence.
textWithoutTags renders a question's sentence with the tag tokens taken
out, for the rows that carry those tags as chips beside it — the handles'
own bargain, where the text carries the value and the chip renders it, so
nothing is read twice. The stored text is never touched.
#src/lib/tasks/
taskEditText / splitTaskEdit (edit.ts): the task row's edit surface,
which is the note page's pattern through the notes' own seam — the first line
is the title, one blank line stands it apart, the details follow. These two
carry only the task-shaped edges: a blanked first line keeps the current
title (a to-do cannot be called nothing), details are end-trimmed and cleared
to null rather than '', and a detail-less task seeds as "title\n\n" so the
editor's end-of-text caret lands ready for the detail.
lineContextAt / NamingContext (src/lib/context.ts): the line that put a
row somewhere, split at the span that did it — one slicer shared by the three
surfaces that derive references from text (the calendar's days, a person's
appearances, a to-do's way back). The preview quotes the line rather than
naming the document, because a title says which note and the line says what is
happening. Withheld wherever the line would only read the row back: a date in
a title, a recurring slot's rule, and a captured to-do whose line is its own
title plus a marker.
selectTasks / taskTagIndex / TASK_SORTS (select.ts): what the flat
view shows, the shape selectNotes set — one pure entry point doing the
filtering and the sorting together, so no screen reimplements either. Open
to-dos only, narrowed by a group set the caller expanded through
groupSubtree and by a tag through sameTag, ordered newest-first, A–Z, or
by day with the undated last (Infinity, so a to-do with no day sorts after
every dated one rather than burying them).
#src/lib/dates/
detectDates (detectDates.ts) scans the same prose for the days it names,
under the same charter and the same architecture (claim spans, fixed pass
order, dedupe by destination day, writer's order; every day, and dateChips
is the first six, MAX_DATES, the strip's ceiling — the note shelf's closed
line counts the whole, and the calendar carries every day a note names). It claims ISO
2026-08-28, slashed dates only with a four-digit year, capitalised
month-name forms in either order (yearless ones roll forward), and the
relative phrases today, tomorrow and in N days/weeks/months. It refuses,
by name: bare m/d (recipe fractions), two-digit years, weekday names,
lowercase months, bare ordinals, and month-or-year alone. It also refuses a
span outright, whatever it holds: a to-do's completion stamp, "(done Sep 3,
2026, 2:41 PM)". That shape lives in notes/completionStamp.ts — its own
module, because the to-do capture reads it too (a stamped line never
re-captures) and neither scanner may import the other — and pinDates walks
the same refusal, so a stamp is never rewritten either. The two scanners
never contest a span — the phone pass refuses date shapes by name, and the
forms claimed here contain nothing the fact passes want.
A relative phrase resolves against the anchor the caller passes — the
document's own write time — and is pinned at save: pinDates appends the
resolved day in parentheses ("in 3 days (Aug 28, 2026)", year always
included), and from then on the parenthetical is the value; the relative words
are never re-resolved, so editing the document cannot move the day. The text
itself carries the date — no schema, offline-merge safe, user-editable.
Day-stepping walks the local calendar (addLocalDays), never milliseconds.
A day reaches the calendar only in brackets. [Sep 5, 2026] is a
commitment; a bare "Sep 5" is a mention. DetectedDate.committed carries the
difference — the test is adjacency, the character before the claim's span and
the one after it, which works unchanged for [tomorrow (Sep 5, 2026)] because a
pinned phrase's span already swallows its own parenthetical. Both kinds still
get a chip, because a mentioned day is worth a glance; only a committed one gets
a square. Where one day is said twice, the committed phrasing wins the strip's
dedupe however late it comes, or the document would carry a commitment the
calendar never saw.
commitLabel writes the brackets, from pinnedDayLabel, so writer and reader
stay the same code. newlyMentioned(previous, next, anchor) answers the one
question the offer needs — did this save add a day the document did not have,
and not commit to it — by comparing whole texts rather than remembering
anything. pinDates still pins inside brackets, and rescheduleDate needs no
bracket handling at all: it rewrites the claim's span, which sits inside them.
Old documents were rewritten, once. migrateToBrackets (src/data/) is
the app's only real data migration, and it exists because the usual answer does
not reach here: everywhere else a schema change is absorbed by a total reader's
default, and the reader IS the migration — but these days live in prose, so
prose had to change. It reads every note and to-do, pins each against ITS OWN
anchor and then brackets what it finds (bracketDates), batches the writes, and
stamps household.bracketsAt last. Pinning first is load-bearing: the write
moves a note's updatedAt, which is the anchor an unpinned "tomorrow" resolves
against, so bracketing without pinning would change the day in the same breath
it recorded it.
It runs from the client (useBracketMigration), because this app has no
operator to run a script. That is safe because the pass is idempotent (a
bracketed day is skipped, so a second device writes nothing), resumable (the
stamp lands only after every write, so a half-finished run resumes), and
faithful (every day that filed before still files after). Households created
since are born stamped, so they never run it.
calendarEntries and monthGrid (calendar.ts) derive the Calendar screen:
text dates from live notes and open tasks, plus recurring tasks' slots walked
forward from each task's stored pending nextOccurrenceAt — the pending slot
renders even when its day has passed; slots before it are not invented. Every
entry carries the GROUP its document lives in — never null, where the field it
replaced threw a note's away — and only narrows the whole derivation to a set
of group ids, which the screen builds from groupSubtree so that filtering to
a folder means everything filed inside it. since is the household's calendar
look-back, and it is applied to the DAY as much as to the document: an entry
leaves only when the document AND the day it names are both behind the
window — an old note's plan for next winter is still a plan, while the day it
filed last spring is a record. Recurring slots are exempt: a weekly chore
made a year ago is re-armed by every completion, so when it was made says
nothing about its age.
pinnedDayLabel exports the pin's day format ("Sep 3, 2026") for the one
surface that writes a document FOR a day — the calendar's own capture — so
the writer and the scanner are the same code.
rescheduleDate is the reverse: it moves a day IN the text that names it,
replacing every phrase claiming that day (a pinned phrase whole, parenthetical
and all, since the pin is the value and the words in front were only its
input; ISO stays ISO). It shares the scan with detectDates, so the day a
chip shows and the day a move rewrites cannot be found by two different rules,
and answers null for text that does not name the day — which is how a caller
learns whether the day lived in a title or a body without scanning twice.
nextDayFor (calendar.ts) is where "the next day" is: one day on for
something still ahead, tomorrow for something whose day has gone, the clock
time carried along for a recurring slot.
uncommitDate takes a day off the calendar the same way: it is the inverse of
bracketDates, removing the brackets round every phrase committing to that day
and leaving the words exactly where they were, so "call the vet [Sep 4]"
becomes "call the vet Sep 4" — off the calendar, still chipped because the note
still names the day, and committed again by typing the brackets back. It shares
the scan and the null contract with rescheduleDate, and frees EVERY committed
phrase on the day for that function's own reason: one entry per day per
document, so a second pair of brackets left standing would leave the entry
where it was. A phrase that merely mentions the day is untouched — it was never
on the calendar to take off it. Only the two characters go and nothing around
them moves, which is a rule you can predict by looking at the text; the price
is that brackets doing some other job around a date come apart like any others,
which is isBracketed's pure adjacency showing through rather than anything
this pass decides.
#The public contract
src/lib/index.ts is the surface layers 2–4 may use. It deliberately withholds
the engine's primitives (median, purchaseIntervals, blendPrior,
confidenceFor, …) so that no consumer can assemble a second cadence path that
skips prior blending, per-unit scaling, or the seasonal gate. groupSlug is
withheld for the same kind of reason: it folds a name for sameGroupName to
compare, and a consumer holding it could turn a name back into a document id —
the derivation opaque ids exist to end.
src/lib/layering.test.ts enforces both the purity of layer 1 and the withheld
surface mechanically.