← All privacy impact assessments
PIA: the baby tracker
Status: Retrospective — written against the code as it stands on 2026-09-07, after the live-timer removal. The feature shipped long before this assessment; that is what "retrospective" means and there is no point pretending otherwise. Its value is the baseline it sets for the next change to this surface. Written: 2026-09-07 · Issues: #221 (register), #330 (parked health-service question) Nothing here is legal advice.
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_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 and parked by owner decision until the app has been tested with families we know (#330). 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, PR #286). 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_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. DISCUSS_WITH_A_PROFESSIONAL in
lib/babyCareGuidance.ts, 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. lib/__tests__/babyCareGuidance.test.ts pins its presence across a
matrix of readings deliberately including the unremarkable ones, and scans every notice the
module 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 agent blocklists that refuse them to the assistant
(lib/babyParameterTargets.ts, agent-server/checks/toolContracts.check.ts 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 tracked in ROADMAP.md.
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 |
Nothing here is encrypted at rest. lib/crypto/ exists and is unit-tested but is wired to
nothing; the only ciphertext column in the schema is review_moderation_log. 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 #223 exists to change — baby
notes are in the Tier 1 list.
3.1 The three paths to a model, and the gate that governs two of them
group_babies.show_recent_summary (default false, 20260817130956) is ONE consent, and
every ambient surface that sends baby logs to a model inherits it. It is read through
showsRecentSummary() in lib/babyLogCards.ts, which fails closed: return baby.show_recent_summary === true.
| Path | What travels | Gated by show_recent_summary? |
|---|---|---|
Tracker summary card → agent-server /api/baby/summary |
An allowlisted digest built by digestFor (agent-server/src/babySummary.ts): only the detail keys in ALLOWED_DETAILS 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: getInsightModel() — google/gemini-3.5-flash-lite. |
Yes, and re-checked server-side. agent-server/src/server.ts fetches the row and returns 403 if it is false, rather than trusting the client. |
Home dashboard insight → generate-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 — get_recent_baby_logs and query_baby_stats in agent-server/src/tools/babyTools.ts |
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 from get_baby_stats_time_series. |
No. These deliberately bypass the gate, and the code says so 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 comment in babyTools.ts 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. - The consent ledger (#219, planned) 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.
WS20 (planned) moves group data, including every baby surface, to Vertex in
australia-southeast1. Until it lands, 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
Owner decision, 2026-09-05. 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 REFUSED_BABY_LOG_KINDS (lib/babyLogProposals.ts):
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).
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 with23503, and__tests__/baby_tracker_rls.test.tsasserts the code rather than the message. 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 handed to the baby-tracker workplan.
5. Deletion
baby_history_deletions (20260905185915) 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.
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 agent 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. #223 is the 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 destructive migration |
| Baby data crosses a border on every model path | the infant | Nothing today; WS20 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 accepted by the owner
- Plaintext at rest. Anyone with operator access can read any infant's record. Stated in the privacy policy; #223 is the fix and it is large.
- 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 WS20 lands.
- 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. Parked to #330 by owner decision. 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 toALLOWED_DETAILSinagent-server/src/babySummary.ts— that allowlist is the egress boundary. - 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 the owner.
- WS20 landing (the region claim changes), #223 landing (the plaintext claim changes), 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;
migrations 20260817130956 (show_recent_summary), 20260905185915
(baby_history_deletions), 20260815173615 (the avatars bucket);
docs/walkthroughs/2026-09-07-removing-live-timers.md; AGENTS.md → Health Content — Baby
Tracker, A Feed Is A SITTING, Not A Row, A Nappy Is A Moment. A Feed Lasts., Deleting A
Baby's History Is A REQUEST ROW, Applied Twice; docs/privacy_posture.md §§1, 4.1, 4.2, 8.
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.