← All privacy impact assessments
PIA: the baby tracker
Amended 30 October 2026: deleting a group. An owner can delete a group. Members are told at once, and the group is deleted seven days later unless the group's owner cancels; meanwhile anyone can export their own data or leave. Deleting removes everything the group holds for everyone in it, children included: messages, photos (sealed or not), lists, routines, baby records, the group's inbox and its attachments, and its keys. Our backups expire within 90 days and a deleted group is not restored from them. A star rating a member gave stays, with no group attached. Copies of the group's events in connected groups say the group no longer exists.
Status: This is a retrospective assessment, written against the code as it stood on 7 September 2026, after we removed the live countdown timers from feed tracking. The feature had already shipped by the time we wrote this; that is what "retrospective" means. Its value is the baseline it sets for the next change to this part of the app. One planned change — a deliberate way to correct how a feed's parts are grouped — was assessed on 18 September 2026, ahead of being built; see §3.3. A second — the growth history, its figure, its CSV, and the assistant's growth card and figure — was assessed on 21 September 2026, ahead of being built; see §3.4. A third — sealing the tracker's own words on the phone — was built on 30 September 2026 against this assessment (§3.5); it has not reached any phone yet and will do so with our next database release and the app update that follows it. Written: 7 September 2026 · Last revised: 30 October 2026 (deleting a group); 20 October 2026 Nothing here is legal advice.
As built, 30 September 2026. The free text a household writes in the tracker — a log's note, a medicine's name, a food, what an immunisation was, a baby's profile note, a handover note, a book's title, a care note's medicine, and the tracker's own summary sentence — will be sealed on the phones that write it, as §3.5 describes. Times, types, amounts, doses, temperatures, growth measurements and a baby's name will stay readable, unchanged. This has been built and merged but not released: it takes effect only once our next database release ships and a member's phone has updated to the app version that writes and reads the sealed form. Until then, and for every record written before that point, the words stay exactly as §3 below describes them today.
Changed 20 October 2026: the tracker's line is written on the phone. The sentence at the top of the tracker card used to be written by a model on our assistant server, from the digest §3.1 describes. From the app update after this change it is written by the app itself, on the phone, from the entries already there: the last few things logged, anything less common from the same day, and the latest note in the parent's own words. Nothing about the baby leaves the phone for it, and it says nothing about whether anything is normal. Phones still running an older version keep using the server until they update. The switch that turns the line on is unchanged for now. It still also gates the home screen's insight and brief, which do send a short count of the day to a model. Its wording, which still describes the old way of writing the line, is being revised.
1. Purpose and scope
A parent, usually exhausted, records what an infant did — fed, slept, nappy, medicine, temperature, a note — so that the other people caring for that infant know, and so that they themselves know at 3am what happened at 11pm. That is the whole purpose. It is the app's most sensitive surface and the one whose design has been cut back hardest.
In scope: group_babies, baby_logs, baby_feed_sessions, baby_feed_due_acceptances,
baby_care_guidance, the baby books tables, baby photos, the deletion machinery, and every path
by which any of it reaches a model.
Out of scope: whether the app is a "health service" under the Privacy Act. That question is open — we have deliberately left it open until the app has been tested with families we know. Nothing in this document waits on it: the app is built to the Children's Code either way, and the answer only decides whether the small-business exemption has already been lost.
The standing position, which is a positioning decision and not an accident: this is an organisational tool and it holds no clinical content at all — no schedules, no thresholds, no intervals, no verdicts. It got there by deletion, three times: the immunisation milestone schedule (2026-08-28), the six app-supplied medicine and fever guidance values (2026-09-02), and the entire evaluation layer that reached conclusions from the household's own numbers (2026-09-02). The third is the one worth understanding, because it shows the first two were about authority, not about numbers.
2. Data involved
| Kind | Whose | Children's? | Sensitive? | Free text? |
|---|---|---|---|---|
group_babies — name, birth date, photo, feeding preferences, display settings, a free-text bio (notes) |
an infant's | yes | plausibly health information | yes — the bio |
baby_logs — log_type, started_at, ended_at, notes, details jsonb, logged_by_user_id, logged_via |
an infant's; attributed to the carer | yes | yes, on any reading | yes — notes, and free-text details fields |
Log types recorded: feed, sleep, nappy, note, medicine, temperature, bath, nasal_aspiration, vomit, tummy_time, growth, solids, immunisation |
medicine (name, dose, unit), temperature (°C, site), growth (weight, length, head), immunisation (free text) are the sharpest |
|||
baby_feed_sessions — a sitting: started_at, ended_at, sealed_by |
an infant's | yes | yes | no |
baby_feed_due_acceptances — the action ref, proposed and accepted occurrence times/details, resulting sitting/component ids, accepting/undoing member and timestamps |
an infant's; attributed to the carers who accepted or undid it | yes | yes | no |
baby_care_guidance — what the household says the limits are: kind, subject, min_interval_minutes, max_doses_per_day, threshold_celsius, source, who confirmed it, when |
recorded by the household, sourced from their own professional | yes | yes | yes — subject and source |
| Baby photos | an infant's | yes | no | — |
baby_history_deletions — deletion request rows |
the group's | no | no | no |
What is deliberately not collected, which is the more interesting list:
- No dose amount in
baby_care_guidance, and no column that could hold one. The limits a household records are intervals and maximums; what was actually given is ababy_logsrow, which is a record of an event rather than an instruction. These two layers have been confused by a reader before — a voice refusal was once designed that would have cost parents the record of a dose ever given — so the distinction is load-bearing. - No vaccine vocabulary.
immunisationlogs a free-textwhatfield. No choice list, no brand names, no dose numbers, no reminder derived from it. A picker would be a schedule by the back door: it goes stale and implies completeness. - No clinical values at all. No fever threshold, no dose interval, no daily maximum, no age-based rule. Deleted 2026-09-02 and they do not come back.
- No evaluation. No
celsius < thresholdcomparison, no "too soon" or "over daily" conclusion, notone: info | check | dueand no amber/red styling. A traffic light is a clinical judgement expressed in CSS, and the quiet green state is the half that does the damage.
The one thing the app does say, and says unconditionally. A constant called
DISCUSS_WITH_A_PROFESSIONAL, rendered verbatim in every medicine and temperature notice with
no branch able to suppress it:
"This app keeps the record; it does not check it. Take these numbers to your doctor or nurse."
The referral is unconditional because silence is a verdict. Referring only when something looks worth referring is a conclusion delivered by omission: a parent told nothing concludes there is nothing to be told. A test pins its presence across a matrix of readings deliberately including the unremarkable ones, and scans every notice this part of the app can produce for verdict vocabulary — normal, fine, safe, high, low, should, recommend, too soon, straight away. Both blocks are mutation-tested.
Dormant, unread schema. medical_milestones, baby_milestone_events and four
group_babies columns (track_immunisations,
is_aboriginal_or_torres_strait_islander, has_medical_risk_condition, jurisdiction) survive
the immunisation retirement because migrations are additive-only. They are unread by any code
path, RLS-guarded, and covered by a blocklist that refuses them to the assistant
(REFUSED_COLUMNS). They are still personal information about a child sitting in a table,
and the honest statement is that they hold whatever was written before the retirement and
nothing since. Dropping them is on our list of planned cleanup work.
3. Flows and locations
| Data | Device | Supabase (Sydney, ap-southeast-2) | Model provider |
|---|---|---|---|
| Every log, session, guidance row | written and read on the carers' devices | stored in plaintext, RLS-scoped to the group | only by the three paths below |
| Baby photos | picked, resized and re-encoded on device | private avatars bucket, avatars/babies/<group_id>/<baby_id>/<ts>.jpg, served by signed URL |
no |
| Deletion requests | minted on device with a client-side id, persisted in an outbox until acknowledged | baby_history_deletions, re-applied nightly for ever |
no |
Revised 30 September 2026: we have built, but not yet released, sealing the words in these records on the phones — see §3.5. The paragraph below describes the tracker as it runs today, before that release, and will keep describing every record written before a phone starts sealing them.
Nothing here is encrypted at rest. Encryption code exists elsewhere in the app and is
unit-tested but is not wired to this feature; the only ciphertext column in the whole schema is
review_moderation_log, which has nothing to do with babies. An operator holding the Supabase
dashboard or the service-role key can read any infant's logs. That is the app's known state, it
is stated in the privacy policy, and it is what our planned work to encrypt data at rest exists
to change — baby notes are on the priority (Tier 1) list for that.
3.1 The three paths to a model, and the gate that governs two of them
group_babies.show_recent_summary (default false, added 17 August 2026) is ONE consent,
and every ambient surface that sends baby logs to a model inherits it. The check fails closed:
unless the stored value is explicitly true, nothing is sent.
| Path | What travels | Gated by show_recent_summary? |
|---|---|---|
| Tracker summary card → a summary endpoint on our assistant server. Older app versions only since 20 October 2026; the current app writes the line on the phone and sends nothing for it | An allowlisted digest of the day's log types and counts, built on our assistant server: only the detail keys allowed per log type (feed: source, side, volume; nappy: status; vomit: kind; sleep/tummy_time/bath/nasal_aspiration: none; note: the free text), plus the baby's bio capped at 500 characters, plus clock-anchored cues. Model: a fast, low-cost hosted model (google/gemini-3.5-flash-lite). The allowlist is the boundary. |
Yes, and re-checked on our server. Our assistant server fetches the row itself and returns a 403 error if it is false, rather than trusting the client. |
| Home dashboard insight | Deterministic count and clock lines only — "last feed 2:15 pm, 5 feeds today, 3 nappies today, 1h 20m of sleep logged today". Never raw logs, never free text. | Yes, plus a second gate: the baby must be on that user's dashboard. |
The assistant's explicit baby reads — two tools the assistant can call directly, get_recent_baby_logs and query_baby_stats |
get_recent_baby_logs returns up to 50 rows: id, log_type, started_at, ended_at, nappy_status, fluid_volume_ml, details, notes — including the free-text notes, fenced as untrusted. query_baby_stats returns daily aggregates. |
No. These deliberately bypass the gate, and this is documented in terms. |
That third row needs stating plainly, because it is the most surprising fact in this document and it is easy to describe the app wrongly around it. The reasoning recorded for that decision reads:
"EXPLICIT CONSENT: The user explicitly asking the private assistant about their baby is a different consent basis than the ambient auto-summary on the home screen. Therefore, this tool (and
query_baby_stats) deliberately DO NOT checkgroup_babies.show_recent_summary."
The reasoning is defensible: an ambient summary generated because a screen was opened is a different act from a parent typing "how many nappies today?". It is not a bug and this assessment does not recommend changing it. But two things follow that a reader would otherwise get wrong:
- No copy anywhere may say that turning
show_recent_summaryoff stops a baby's data reaching a model. It stops the two ambient surfaces. A parent who asks the assistant directly still sends logs, including free-text notes. Any privacy tier promising "no baby data leaves the device" is false however the switches sit. - A planned consent ledger should record the explicit-ask basis as its own thing, rather
than folding it under
baby_summary_to_model. Two different bases recorded as one purpose is a record that cannot answer the question it exists to answer.
Region. All three model paths run wherever the assistant currently runs, which today is
OpenRouter → Gemini with no Australian processing — the router decrypts in the United States.
We plan to move group data, including every baby surface, to Google's Vertex platform in its
Australian region (australia-southeast1). Until that happens, an infant's feed log crosses a
border when a parent asks about it, and the cross-border notice must say so (exposure draft s 26).
3.2 Voice never writes a baby record
As of 5 September 2026, a spoken baby request becomes a BABY_LOG_PROPOSAL card in
action_stacks (status: 'pending') and writes nothing until a person confirms it on screen —
strictly stronger than a spoken "yes" heard once by a room, because the values are visible and
editable on the card. Proposable kinds: feed_log, feed_end (legacy), nappy, sleep,
note, medicine. Refused by name in a blocklist called REFUSED_BABY_LOG_KINDS:
immunisation (a model must not normalise that free text into a vocabulary), care_guidance
(the household's own recorded limits are not a model's to touch), and temperature and growth
(a clinical reading and a measurement nobody actually took).
Revised 21 September 2026 (§3.4): growth leaves the refused list for the typed
assistant only, because the card is exactly the safeguard the refusal reason asked for — the
number is on screen, in the parent's own units, before it is a row. The spoken door still
refuses it: it has no screen to show the number back on, and "6.2" and "6.7" are one misheard
syllable apart.
Revised 9 October 2026: the spoken door now raises the same growth card. What the refusal
protected against — a misheard number becoming a record — is answered by the card, not by the
voice: nothing is written until a member has read the number on screen, in kilograms and
centimetres, and confirmed it, and the spoken reply says only that a card is waiting, never the
number. No new data reaches anyone: the spoken sentence already went to the voice extraction
model, which used to answer "not understood" for growth; it now fills three number fields
instead. The card's numbers sit in action_stacks like a typed growth card's — plaintext,
household-visible, the same as the row they become. Structural: the voice door has no path
that writes a growth row; the server refuses a number outside the tracker's bounds and the
app's resolver checks again on confirm. Residual, accepted: a member can confirm a misheard
number without reading it, which they can equally do by mistyping one.
3.3 Planned: correcting how a feed's parts are grouped (assessed 18 September 2026, not built)
The app already joins a nurse and the top-up bottle that follows it into one sitting automatically. When that join gets it wrong — two parts of one feed left separate, or two separate feeds merged — a carer today has no honest remedy short of deleting and retyping, which is a worse record. The planned change adds an explicit correction: combine an entry into a nearby sitting, move it out, or split a sitting, previewed before it is confirmed, applied by one transactional operation on our server through the carer's own session.
What matters for privacy, because a health record is what is being edited:
- No new data is collected. A correction rearranges rows that already exist. Component times, volumes, sources, and — critically — who logged each entry are preserved: a regroup must never rewrite attribution, so the record keeps saying who actually did the feed.
- Rows move; they are never re-minted. The group's deletion machinery for a baby's history works by a cutoff against when a row was created, re-applied for ever. A correction that copied old entries into fresh rows would quietly carry content past a deletion the household had already asked for. The correction updates rows in place, so a deletion request keeps meaning what it meant.
- Same baby, same group, only. Combining across babies or across groups is refused by the operation itself, not by the screen declining to offer it.
- A manual regroup teaches the app nothing. The learned join window that auto-groups future feeds trains only on sittings a person genuinely observed; a duration produced by a correction is excluded, so one fix cannot skew how every later feed is grouped.
- No model is involved at any step, and nothing about this change touches what reaches a model (§3.1 is unchanged by it).
Every active member can correct, the same as every active member can already edit a log — the group is the trust boundary here, as everywhere in the tracker.
What it inherits, stated rather than left to be assumed — because a correction touches the most sensitive rows in the app, and "nothing changes" is a claim that has to be made explicitly to be checkable:
- Access: unchanged. The rows are already scoped to active members of the group (§4), the operation runs through the correcting member's own session, and the composite key that stops a log being attached to another group's baby applies to a moved row exactly as it applies to a new one.
- Encryption tier: unchanged, and that means plaintext at rest, as §3 says of every baby row today. This correction neither improves nor worsens that; it must not be described as making a record safer, and it must not become an excuse to copy rows somewhere less protected.
- Retention and deletion: unchanged, and this is the property the design is shaped around. Because rows move rather than being re-minted, a moved entry keeps the creation time a deletion request's cutoff is measured against, and the nightly re-application of every deletion request keeps reaching it. A correction is not a way to bring deleted history back.
- Residency: no new destination, which is the accurate form of the claim rather than "nothing crosses". There is no model call in this path at all, so the region question that governs §3.1's three paths does not arise for it, and the correction itself is a database operation on rows that stay in our Sydney database. But a correction updates a feed sitting, and updating a sitting is exactly what the Lock Screen glance watches — so a correction can refresh a glance, and that refresh goes to Apple's push service overseas, carrying the same small timing fact a new feed's own refresh already carries, for a baby whose settings allow a glance at all. That is the existing path, unchanged and not widened by this feature; it is named here because "nothing crosses a border" would have been false, and a correction is the kind of quiet write that makes it easy to forget a trigger is watching.
- Privacy level: unchanged. This is a database operation on the group's own rows, not a server reading sealed content, so a Device-only group gets the correction like any other.
- Child accounts: the tracker is not a child account's surface, and this changes nothing about that.
Structural versus conventional, for this change specifically. Structural: the same-baby and same-group refusal, enforced inside the operation rather than by the screen not offering it; the whole correction succeeding or failing as one, so a half-applied regroup cannot exist; rows moving in place, which is what preserves both attribution and the deletion cutoff; the existing access rules, which are not re-derived. Conventional, and to be test-pinned: the preview showing what the result will be before it is confirmed; the exclusion of a manual regroup from the learned join window; and the absence of any clinical reading of the corrected totals — a regrouped feed produces a different number, and nothing may comment on whether that number is good.
The risk this adds, and the residual we accept. Risk: a correction is an edit to a health record, so a member can regroup entries into a sitting that never happened — a longer nursing duration, or a bottle counted once that was drunk twice — and the resulting total then appears beside the real ones with nothing distinguishing it. Who is harmed: the infant, if a carer later reads a wrong total as fact. Mitigation: component times, volumes, sources and attribution are preserved by the operation rather than recomputed, so a regroup rearranges observations instead of inventing one; the preview shows the result first; and the learned join window is not trained on it, so one bad correction cannot propagate into how every later feed is grouped. Kind: structural for the preservation and the transactionality, conventional for the preview and the training exclusion. Residual, accepted: a member who wants to record a feed that did not happen can already do so by typing one, and a correction is not a new capability so much as an honest route to a fix people currently achieve by deleting and retyping — which loses attribution and provenance, and is the worse record this change exists to prevent.
When this ships, this retrospective is revised against the code.
3.4 Planned: growth history, figure, CSV, and the assistant's growth card and figure (assessed 21 September 2026, not built)
Four things change, and each is assessed on its own because each fails differently. The design they implement is the growth-history plan in the repository's design documents, cited in §10.
(a) Reading a baby's growth history all the way back. Today every tracker surface reads a
fourteen-day window of baby_logs. The history screen, a new growth screen and (d) below read
one baby's date-level rows (growth, solids, immunisation) with no lower bound, capped and
paged. No new data is collected and nobody new can read it: the read is the member's own
session against the same SELECT policy (§4). What changes is that a device holds more of a
child's record in memory while one of these screens is open; it is not persisted beyond the
existing store window, and it passes through the client half of the deletion machinery
(stripDeletedBabyRows) before it is shown, so a deletion this phone has asked for and the
server has not yet applied hides the same rows the server will.
(b) A figure of the household's own numbers. Weight, length and head against the child's age, one panel each, drawn from (a). This is recall, and the line it must not cross is comparison: no percentile or reference curve, no target, no colour by value, no arrow that means "good". The referral sentence (§2) renders under it unconditionally, with a second sentence that the clinic holds the clinical record. Conventional, and pinned the way the medicine and temperature notices are: a vocabulary scan over every string the module can produce, and a presence test for the referral across a matrix of data shapes including the empty one.
(c) A CSV a person can open in Excel. Built on the member's device from rows already on
it, written to the app's cache directory, handed to the operating system's share sheet, and
deleted when the sheet closes. Columns: the day (group timezone), the child's age, each measure
in kilograms/centimetres and in the stored grams/millimetres, the free-text note, the display
name of the member who logged it. The row id is omitted (revised 3 November 2026).
Structural: there is no server in this path — nothing about the export can be read, logged or
retained by us, because it never reaches us.
Code: free-text cells are escaped so a note beginning = cannot execute in a spreadsheet;
the file is removed after sharing. Residual, accepted: from the moment the share sheet opens
the file is in the hands of whatever the member chose, and the screen says so beside the
button. Member names travel in the file — a decision taken when this was designed; they are
attribution the household already sees on every row.
(d) The assistant. Two capabilities.
- Writing growth. The typed assistant may today insert any log type directly, and its schema
for growth carries no unit and no bounds — a model that turns "6.2 kg" into
weight_g: 6.2writes a six-gram row that nothing refuses. After this change the assistant's growth tool raises aBABY_LOG_PROPOSALcard and inserts nothing: the card shows the numbers back in kilograms and centimetres, on the household's screens, editable, and a member confirms it through the same resolver the spoken door's cards use. Every model-facing field carries its unit in its name (weight_kg,length_cm,head_cm); the server converts and bounds-checks with the same factor table the app uses, and a CI check fails if the two drift. What reaches the model: the sentence the parent typed, and the tool's reply that a card is waiting. No log is read on this path. - Showing the figure in the chat. The model is given a tool that reads no rows: it
resolves the baby's name against the server-read roster and returns a typed marker naming the
baby. The phone stores only that pointer (
{ kind, baby_id }) on the assistant message —metadatais an unsealed column, which is exactly why it must carry no measurement — and draws the figure itself, from (a), with the same component as the growth screen, with the same referral under it. Two designs were refused: a server-rendered image (a new plaintext location, a picture that outlives a later deletion) and a model-emitted chart (the model would choose the numbers — a fabrication surface that looks fine in review). The figure is a live view, and says so: a measurement corrected or deleted later changes the old bubble too, which is the only behaviour compatible with the deletion sweep. The model is instructed not to describe what the figure shows, and the transcript replayed to it carries text only, so it cannot.
Revised 9 October 2026 — two follow-ups built on (a) and (c). Older stream entries: feeds, sleeps, nappies and the other timed types can now be read past the fourteen-day window on the history screen, one page per tap and never without one, by the same session against the same policy, with the same deletion strip — including the feed sittings those rows belong to. What changes is again only how much of a child's record a device holds while that screen is open; nothing is stored beyond it. Export of any one type: the history screen's CSV is (c)'s file for whichever single type is filtered — the rows listed, built on the device, deleted after the share sheet, free text escaped, the screen saying beside the button that a shared file is in the hands of whatever it was sent to. No server is in either path.
What it inherits. Access: unchanged (§4). Encryption tier: unchanged — plaintext at
rest; the CSV and the chat pointer neither improve nor worsen it and neither may be described as
doing so. Retention and deletion: unchanged for rows; the CSV is transient by construction and
the chat figure is a pointer the sweep reaches through. Residency: no new destination. The
assistant paths run under the existing baby-domain terms (§3.1); the figure tool sends the
model nothing it did not already have from the roster. Consent: show_recent_summary gates
sending logs to a model, and none of this sends one — the figure is the viewer's own device
reading rows RLS already grants, on the explicit-request basis get_recent_baby_logs states.
Child accounts: the tracker is not a child account's surface; unchanged.
Structural versus conventional, for this change. Structural: no server code path inserts a growth row for the assistant (the write is a proposal, and the proposal's resolver is the last gate, in stored units); the figure pointer carries no data; the CSV never reaches a server; the model never sees a baby id, and the client re-verifies the pointer's id against its own roster before drawing. Conventional, test-pinned: the absence of verdict vocabulary on the screen, in the bubble, in the tool description and in the model's reply; the referral's presence; the escaping of free-text cells; the deletion of the exported file.
The risk this adds, and the residual we accept. Risk: a growth chart is the surface on which every family app draws a percentile curve, and parents will ask for one — of the screen and of the assistant. Who is harmed: the infant, through a parent reassured or alarmed by a curve the app has no standing to draw. Mitigation: the positioning decision of 2 September 2026, carried in the tool description, the prompt, the vocabulary scans and the design plan. Residual, accepted: a parent can still mistype a weight and confirm it, on the sheet or on the card; the edit screen gains the ability to correct growth values so the remedy exists, and the app draws no conclusion from the number either way.
When this ships, this section is revised against the code.
3.5 Built, not yet released: the tracker's words sealed on the phones (30 September 2026)
The review trigger in §9 — "data at rest being encrypted" — has fired for the words. Under the wider assessment of sealing the remaining free text (the content encryption assessment), the free text a household writes in the tracker will be sealed on its phones under the part of the group's key that also protects event notes and places. This has been built and merged, but it has not reached any phone: it takes effect once our next database release ships and a member's app has updated. Once that happens:
- Sealed: a log's note; a medicine's name, a food, and what an immunisation was (the three free-text details — every other detail stays plain); a baby's profile note; a handover note; a book's title; a care note's medicine and where it came from; and the tracker's summary sentence and its cues, which our assistant server writes and seals as it writes them.
- Not sealed, on purpose: a baby's name, times, types, amounts, doses, temperatures and growth measurements. The app counts, charts and reminds from them, the assistant matches a name, and a lock screen shows one. These stay readable by an operator, as §3 says.
- One note per medicine is kept by a keyed fingerprint of the medicine's name, made on the phone. It tells the database which notes name the same medicine and nothing else, and it changes when the group's key moves to a new version, so the phone matches by the opened name first.
- Who can still open them: the group's phones; and at the Australia and Global levels our assistant server, inside one member's own request, the same way it opens event notes — to answer a question about a baby's day and to write the summary sentence. Every such opening is counted in the member's key access record. In a Device-only group nothing on our side can.
- What is not yet sealed: records written before the update, and any written while a phone did not yet hold the key (the assistant writes plaintext when it cannot seal, rather than refuse to record a feed). A member's phone seals those from the Devices & keys screen, and the database, not the phone, decides when a field is complete. A refusal to accept new plaintext exists and is switched off; we switch it on per group only once the minimum app version is past every version that writes plaintext.
- Pushes: a handover notification no longer carries the note at any level.
- Portability: "Download my data" returns the sealed words as stored; "Export baby records" in the app's settings writes them out as text on the phone, and the file is deleted when the share sheet closes.
What this does not change: an operator can still read the structured record (§8), the three paths to a model in §3.1 are unchanged, and the consent that gates the summary is unchanged.
4. Who can see it
- The active, approved members of the group, and nobody else. Every baby table is
RLS-scoped on
is_active_group_member(group_id). - The attack the schema is shaped around is attaching a log to another group's baby, which
RLS alone would permit because the attacker supplies their own
group_id. A composite foreign key on(baby_id, group_id)refuses it with error code23503, and a test checks for that code directly, rather than trusting an error message that could silently change. logged_by_user_idis pinned toauth.uid()on INSERT — a member cannot write a log as somebody else. It is deliberately open on UPDATE, so a carer can correct who actually did it.authenticatedholds no DELETE ongroup_babies. A baby is archived, not deleted, except through the deletion request path in §5.- A baby profile lives in one group, so an infant with two homes is two unlinked records. Known, not solved, and on our list of future baby-tracker work.
5. Deletion
baby_history_deletions (added 5 September 2026) lets a group delete one baby's history, or the
baby, from that baby's own settings behind a typed confirmation. The design is unusual enough to
be worth recording as a privacy property rather than an implementation note:
- The request row is the durable fact and is never swept, expired or deleted.
sweep_baby_history_deletions(nightly, 04:10 UTC) re-applies every row for ever, which is what makes the deletion survive a database restore or a week-old offline write landing late. A row that looks done is not done; there is no done. This is the same reasoning as the off-provider erasure ledger: an erasure record that shares a fate with the thing it evidences is not evidence. - The cutoff is
created_at < requested_at, neverstarted_at. A feed backdated after the deletion is a new row and survives, on the phone and in the database alike. - The client mints the request id and retries until the RPC answers, so a retry after a lost
response files nothing extra (
on conflict (id) do nothing). - The marker is the request row, rendered — never a "History deleted"
baby_logsrow, which the sweep would delete and the stats RPCs would read.
Account erasure removes an account's data through the cascade and the storage sweep, evidenced
in the GCS erasure ledger; delete-account fails closed if the ledger row cannot be written.
baby_feed_due_acceptances is a narrower durability record. It remains after an Undo so a
delayed or restarted client cannot replay the same action ref and recreate the feed. Members have
no direct table privileges; the two RPCs enforce active membership, and the row is removed with
the baby or group. Erasing the accepting or undoing member sets that attribution to null while
retaining the household idempotency fact.
6. Risks and mitigations
| Risk | Who is harmed | Mitigation | Structural or conventional | Residual |
|---|---|---|---|---|
| The app states a clinical value and a parent acts on it at 3am | the infant | No clinical values exist. Deleted, and the deletions are pinned by mutation-tested guards | Structural-ish — an absence, held by tests that fail loudly | Accepted. A future change could add one; the tests are the guard |
| The app reaches a conclusion about a reading | the infant | The evaluation layer is deleted; one neutral notice style; unconditional referral; a verdict-vocabulary scan over every producible notice | Tests, mutation-tested | Accepted |
| An operator reads an infant's health record | the infant | RLS bounds everyone except the operator. Nothing more | — | Accepted, and stated in the policy. Encrypting data at rest is the planned fix |
| A parent believes turning the summary off stops all model access | the infant | Nothing today | — | Accepted, and the most important copy risk on this surface. §3.1 |
| Free-text notes reach a model on an explicit ask | the infant | Fenced as untrusted; allowlisted elsewhere; but get_recent_baby_logs returns notes |
Conventional | Accepted — it is what the parent asked for |
| A separated ex-partner, still a member, reads logs about a medical appointment | an adult; the infant | Nothing. Group membership is the boundary | — | Accepted. Leaving the group is the remedy; per-member visibility inside a group is deliberately not built |
| A log is attached to another group's baby | that infant | Composite FK | Structural | — |
| An infant's record follows them into adulthood | the child, later | The deletion request path; per-item deletion; account erasure | Structural where used | Accepted. §7 |
| The dormant immunisation columns still hold data | a child | Unread, RLS-guarded, blocklisted from the assistant | Structural for access; the presence is unmitigated | Accepted until the columns are dropped |
| Baby data crosses a border on every model path | the infant | Nothing today; moving processing to Australia is the plan | — | Accepted, must be disclosed (s 26) |
7. Best interests of the child (exposure draft s 10)
The infant here has no view and will not have one for a decade, which is exactly why this section is not a formality.
What the child gets. Continuity of care between the people looking after them. Two carers who both know when the last feed was is a real safety benefit, and it is the reason the feature exists. The design work that made a feed a sitting rather than a row, and a log a closed length rather than a running timer, was all in service of the record being true — a wrong number in a health record is the harm, and "the app was confidently wrong about a baby" is the failure mode the architecture is shaped against.
What it costs them. A permanent, timestamped record of their infancy — feeds, nappies, medicines, temperatures, and their parents' notes — held in a database they never consented to, readable by an operator, and reachable by a model on a parent's request. They will one day be an adult with opinions about that.
Where the child's and the parent's interests diverge, and who won.
- On collection: the parent. The record exists because a parent needs it. The child's interest is served indirectly, through better care.
- On authority: the child. Repeatedly and expensively. The app could be far more useful to an exhausted parent if it said "that's a normal temperature" or "you can give the next dose at 4pm". It says neither, and the reason is the child: an app that is confidently wrong about an infant's medicine at 3am is a worse outcome than an app that is merely unhelpful. A disclaimer is not a licence to state the number — the number is what a parent remembers at 3am and the sentence beside it is not.
- On what travels to a model: mixed. Ambient surfaces are gated and send counted, never characterised, facts — "5 feeds", never "plenty of feeds", and never a word about whether any number is normal. Explicit asks are not gated and send free text. §3.1.
- On deletion: the child. The deletion machinery is stronger than it needed to be — a request row that outlives a restore — and that is a decision made for a person who cannot ask.
What the child is told. Nothing; they are an infant. The obligation runs to the guardians, and the app's answer is that every notice tells them the app is not checking anything and points them at a professional.
8. Residual risk we accept
- Plaintext at rest, for the structured record. Anyone with operator access can read an infant's times, types, amounts, doses, temperatures, measurements and name. We have built, but not yet released, sealing the words a household writes (§3.5); once released, records written before that point will stay readable until a phone seals them. Stated in the privacy policy.
- The explicit-ask path bypasses the one consent. §3.1. Accepted as a deliberate design decision; the risk being accepted is that the app's own copy will drift into implying otherwise.
- Baby data is processed overseas today. Until we complete the planned move to Australian processing.
- No per-member visibility inside a group. Every active member sees every log. Accepted: intra-group privacy is more groups, not roles inside one.
- The dormant immunisation schema still holds whatever was written before the retirement.
- The health-service question is unanswered. We have deliberately left this question open until the app has been tested with families we know. The app is built to the Code either way.
- This assessment is late. The feature shipped in August 2026; this is September.
9. Review trigger
- Any new
log_type, any new field on an existing one, and any change to the allowlist of detail fields sent to a model on our assistant server — that allowlist is the egress boundary. - The feed-grouping correction (§3.3) being built — this document is revised against the code, and the two properties that must be re-checked as shipped facts are that rows move rather than being re-minted (the deletion cutoff depends on it) and that a manual regroup is excluded from the learned join window.
- The growth work (§3.4) being built — this document is revised against the code, and the
properties to re-check as shipped facts are: no server path inserts an assistant growth row;
the chat figure pointer carries exactly a kind and an id; the CSV file is deleted after the
share sheet closes; and the unit factor tables agree under
npm run check. - Any fourth surface that sends baby data to a model, and any change to which surfaces check
show_recent_summary. - Any reintroduction of a clinical value, a comparison, a threshold, a schedule, a vocabulary or a severity style — which would be a change of position, not a feature, and needs our explicit sign-off.
- Model processing moving to Australia (which changes the region claim), data at rest being encrypted (which changes the plaintext claim), and the dormant columns being dropped.
- Child accounts shipping — the tracker is hidden from a child account and that becomes a property to prove rather than assert.
10. Sources
In repo, verified 2026-09-07: lib/babyLogTypes.ts, lib/babyLogCards.ts
(showsRecentSummary), lib/babyCareGuidance.ts (DISCUSS_WITH_A_PROFESSIONAL),
lib/babyLogProposals.ts (REFUSED_BABY_LOG_KINDS), lib/babyInsightCues.ts,
lib/babyHistoryDeletion.ts, agent-server/src/tools/babyTools.ts (the bypass and its
reasoning), agent-server/src/babySummary.ts (ALLOWED_DETAILS), agent-server/src/server.ts
(the 403), __tests__/baby_tracker_rls.test.ts,
lib/__tests__/babyCareGuidance.test.ts;
database changes adding show_recent_summary (17 August 2026), baby_history_deletions
(5 September 2026), and the avatars bucket (15 August 2026);
docs/walkthroughs/2026-09-07-removing-live-timers.md; our internal engineering rules on
health content in the baby tracker, on a feed being a sitting rather than a row, on a nappy
being a moment, and on deleting a baby's history being a request row applied twice;
docs/privacy_posture.md §§1, 4.1, 4.2, 8.
In repo, verified 2026-09-21 (§3.4): docs/baby_growth_history_and_export_plan.md (the
design), lib/babyGrowth.ts, lib/babyCsv.ts, lib/babyCsvExport.ts, lib/babyLogHistory.ts,
lib/babyLogProposals.ts (resolveGrowth), components/Baby/GrowthChart.tsx,
components/Assistant/GrowthFigureBubble.tsx, agent-server/src/babyFields.ts
(GROWTH_MEASURES, parseGrowthDetails), agent-server/src/tools/babyTools.ts (the growth
branch of log_baby_event, show_baby_growth), agent-server/src/aggregate.ts
(collectFigures), agent-server/checks/babyFigures.check.ts,
agent-server/checks/babyLogTypes.check.ts (the unit-factor comparison).
Not re-read for this document: the Children's Code exposure draft itself; its section
numbers here come from docs/privacy_posture.md §4.2.