← All privacy impact assessments

PIA: the group photo wall

2026-11-03: the wall no longer states that its photos are encrypted once every photo is sealed (founder); the not-yet-encrypted notice and the admin's job remain, so the wall still never implies more than is true.

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.

Corrected 21 October 2026 (built; it reaches production when the edge functions are deployed at the next batched release). Erasing the last member of a group now also removes the group's sealed photos from storage. Before this, they were left behind, and until that deployment an erasure in production still leaves them. A known residual, which Security is scoping: the erasure reads the group's members first and sweeps after. Someone who joins after that read can have the group, now shared with them, deleted along with its photos. A photo uploaded after the sweep has listed the group's storage can be left behind. Both were already true of plain photos and of the group itself.

Status: As of 9 September 2026, photographs, captions and album names are sealed on the device before they are uploaded, and the event-group wall named in §10 as a review trigger has been built. Revised on 18 September 2026 for chat attachments, and revised again on 24 September 2026: chat-attached photos are separated from the wall via group_chat_photos until explicitly saved by a member, and sealed photo comments (group_photo_comments) are added without engagement metric tallies. Written: 7 September 2026 · Last revised: 3 November 2026 (all-sealed wall notice removed); 30 October 2026 (deleting a group); 21 October 2026 (erasing a group's last member removes its sealed photos too; built, awaiting the edge-function deployment) Nothing here is legal advice.

The Children's Code exposure draft names "family photo sharing applications" as an example of a covered service (s 5, Note 1). This is that module, verbatim.

The one claim to be careful with. A row carrying encrypted = true is sealed: three variants, caption and album name, under a key that exists only on members' devices. A row carrying encrypted = false is a photograph uploaded before 9 September 2026 and is plaintext in the bucket, until an admin's device has run the history job over it. Both states are live at once and will be for as long as any group has not finished. Every statement in this document is therefore scoped to one or the other, and any copy that says "your photos are encrypted" without that scope is wrong. The wall shows the not-yet-encrypted notice while older photos still need encrypting, and no encryption notice once every photo is sealed.


1. Purpose and scope

A group's shared photographs, in one place, visible to that group and nobody else. Albums where somebody made one, and otherwise grouped by the month the photograph was taken in.

In scope: group_photos, group_chat_photos, group_photo_comments, group_photo_albums, group_photo_likes, group_photo_reports, group_photo_copy_removals, the group_media storage bucket, the upload pipeline, the report-and-delete flow, the sealing of content on the device (from 9 September 2026), the event-group wall and the copy flow (built the same day, and also assessed in the cross-group events assessment, §8), and the history job that seals what is already in the bucket.

Out of scope: dish photos on public meal-plan reviews were a different surface; they were removed on 21 October 2026, when reviews became stars only; user and baby avatars, which live in the avatars storage bucket and are not sealed by this encryption work.

2. Data involved

Kind Whose Children's? Sensitive? Free text?
The photograph itself, in three variants the group's; the subjects' frequently yes — this is a family app a photograph can show anything —
group_photos.caption the uploader's words about the subjects often about a child possibly yes
taken_at, width, height, pinned, uploaded_by, storage_prefix, album_id no
group_photo_albums.name, and an optional link to a group_schedule_events row the group's yes — the name
group_photo_likes (photo_id, user_id, created_at) — dormant since 8 September 2026: likes were removed for every member; nothing reads or writes this from the app the liker's, historically no no
group_photos.like_count — an integer column maintained by trigger, dormant: no surface reads it and a test asserts a stored value moves nothing no no
group_photo_reports (photo_id, reported_by, reason) the reporter's yes — the reason
group_photos.key_version, wrapped_data_key, encrypted (added 9 September 2026) no — a wrapped key is inert without a device key no
group_photos.caption_sealed, group_photo_albums.name_sealed (added 9 September 2026) the writer's words often about a child ciphertext the plaintext is
group_photos.source_photo_id, source_group_id, source_label_sealed (added 9 September 2026) provenance of a copy the label is ciphertext; the two ids are not the label is
group_photo_copy_removals (added 9 September 2026) the source uploader's decision no no

What is not collected, and it is a good list:

3. Flows and locations

Data Device Supabase (Sydney, ap-southeast-2) Model provider
The photograph, sealed (from 9 September 2026) picked, decoded once, re-encoded into three bounded variants, then each variant sealed a chunk at a time into a file private group_media bucket, sealed/<group_id>/<photo_id>/<variant>.enc — ciphertext only never
The photograph, not yet sealed (uploaded before 9 September 2026) as before photos/<group_id>/<photo_id>/<variant>.jpg, plaintext, until the history job re-uploads it never
Caption and album name, sealed sealed under the item's own data key caption_sealed / name_sealed, ciphertext never
Caption and album name, not yet sealed caption / name, plaintext never
Reports (reason) stored in plaintext — see §5, it is addressed to the group's admins, not to us never

How the sealing works, in one paragraph. Each photo gets its own data key, wrapped under the group key at its current version; the wrap goes on the row, and the key itself never leaves the device. The three JPEG variants are sealed a 64KB chunk at a time into a file and the ciphertext is uploaded, which is what keeps a full-size photograph out of memory as one buffer — the trap an earlier, since-deleted metadata-stripping approach fell into. Reading decrypts once into a plaintext file cache keyed by photo id and key version, capped at 200MB and cleared on sign-out and on leaving a group, so a photograph costs crypto exactly once per device.

What an operator holding Supabase now gets, for a sealed row: the ciphertext, a wrapped data key that only a member device can open, taken_at, uploaded_by, the album id, the dimensions, and the fact a photograph exists. Not the image, not the caption, not the album's name. For an unsealed row: everything, as before.

Tier 1 does not exist on web. Secure on-device key storage has no web equivalent, so a web session has no key to hold and the wall says so instead of falling back to anything. There is no web build published today; this is the invariant being honoured rather than a shipped surface.

Three variants, and the size is always chosen explicitly: thumb 400px on the long edge for grids and rails, display 1440px for the viewer, full 2560px only for saving. Pulling full into a small tile is what makes the wall feel slow on cellular and is invisible on wifi. The resize is on the device, so the bytes that leave the phone are bounded — a privacy benefit as well as a performance one, since an untouched original carries more of everything.

The bucket is private and must stay private. A public bucket serves every object to anyone who has ever seen the URL, after deletion included, and half of what is in there is photographs of children. Rendering goes through short-lived signed URLs (15-minute expiry, with in-memory caching so the same image is not re-fetched needlessly).

Nothing here is encrypted at rest. Changed from 9 September 2026, and only forwards. A photograph added from a device holding the group key is sealed before it is uploaded. Everything already in the bucket stays plaintext until an admin's device finishes the history job, which re-uploads each photograph at the sealed path and removes the old objects. Until then an operator holding the Supabase dashboard can open exactly the photographs the job has not reached — which is why the policy claim is dated, and why the wall says the history is still in progress rather than implying it is done.

Nothing on this surface ever reaches a model. No caption, no image, no album name. This has not changed with the photo encryption released on 9 September 2026, nor with the copy flow.

Attaching a photo from the chat composer and Photo Wall separation (revised 24 September 2026)

A member can attach a photo while writing a group chat message, instead of leaving the conversation for the wall and coming back:

Photo Comments (added 24 September 2026)

Members can add comments to photos on the wall:

The lifecycle decision, which is the part a second door most easily gets wrong. There are now rows pointing at one photograph — the photo, the chat record, and any message that links it — and each can be deleted on its own. The design settles it in one direction and says so on both screens:

Which of these the database will hold, and which it will not. Structural: the photo's own access, sealing and deletion rules, which are the wall's existing ones and are not re-derived here; the message's link to the photo row being ON DELETE SET NULL, so deleting the photograph empties it in the same statement and leaves no identifier on the message that could fetch a deleted image — and, equally, so the message itself survives the deletion rather than cascading away with it; the single identity that makes a retry idempotent. Conventional, and to be test-pinned in the build: the write ordering (message last) and the cleanup after a partial failure; the "photo was removed" rendering the marker and the emptied link together produce; the attach sheet's sentence that the photograph is added to the group's photos; the absence of any engagement affordance on a photo in chat.

When this ships, this assessment is revised against the code; §10's triggers apply to the new door in full (in particular: any path that skips the re-encode, and any surface that shows the photo outside its group).

4. Who can see it, and what is displayed

There are no likes. Removed for every member on 8 September 2026 — the heart, the grid badge, the named likers in the viewer, and the tap that wrote one. This section previously described an intermediate position (the numeric tally dropped on 7 September 2026, attributed likes kept, on the reasoning that a heart is an act addressed to the poster where a read cursor is surveillance) and this document went on describing that position as current after it had been reversed; corrected 18 September 2026. The reversal's reasoning is in the ban self-assessment, §5.3: naming three likers conveys the extent of others' engagement as surely as printing "3", so attributed likes were still a feedback feature within Rules s 4A(5)(a), and the app's position is settled by having none.

What remains is dormant schema, and honesty requires naming it: group_photo_likes, the group_photos.like_count column and its maintaining trigger still exist — our schema changes are additive-only, and app versions from before the removal still select those columns. Nothing reads or renders them, a test asserts a large stored count cannot reorder or badge anything, and a standing source scan keeps like/reaction affordances out of these surfaces. But the tables are still writable by an active member driving the API directly, so "the app has no likes" is a statement about the client, not a database guarantee — the same honest scoping the rest of this register uses for conventional properties.

A pin is not a like and stays. A member marking a photo as worth keeping to hand is not a report on anybody's response to it.

5. Safety: delete and report, and no AI

Reporting a photo inserts a group_photo_reports row. The user-facing copy is "This sends the photo to us to review."

A REPORT GOES TO THAT GROUP'S ADMINS, NOT TO US. Our code never opens a photograph, so "sends the photo to us to review" would promise a review nobody performs. That is a rule, not an inability: at every level but Device-only a copy of the group's key is kept for our assistant server (which never uses it for photos), and a photo from before 9 September 2026 is plaintext until the history job reaches it. The people who act are the group's own admins, who can see the photograph and delete it. No service-role reader of reports exists in any of our backend functions, and none may be added — that would be a decrypt path built for us, which our own rule against ever building a decrypt path forbids outright.

Reporting the person to GroupApp (added 27 October 2026, HQ PIA-007 F10). A photo report on the wall still goes only to the group's admins. The photo viewer will also offer "Report the person to GroupApp": a member report, holding a category and a note and never the photograph (member-safety-reports-and-blocks.md). A chat photo can be reported to GroupApp too, but only its caption as the phone shows it travels, with photo: true. Neither copies a stored object, and neither is a service-role reader of photos: the rule above stands.

What a report does (revised 10 October 2026). Until then a report stored a row that no screen read and nothing announced, while the alert said "This tells the admins of this group… Photos here are encrypted, so we cannot see them ourselves" and thanked the reporter with "we will take a look"; all three claims were false. From the app update that carries this change:

The reporter is still not named, and the mechanism is worth knowing. The table's own SELECT policy is unchanged — a row is readable by the reporter alone — and admins read through photo_reports_for_group, a SECURITY DEFINER function that returns the photo, the reason and the date and does not select reported_by at all. RLS is row-level and could not have done this; a policy admitting admins to the row would have handed them the name. A reporter who can be identified by the person they reported, in a four-person group, is a reporter who does not report — that reasoning survives unchanged, and it is now held by a function's select list rather than by a policy.

There is no AI moderation on this surface, and that is a decision. These photos are visible only inside one group, whose members join by its own code and approval or a direct invitation. Delete plus report is the whole safety story, and it keeps a group's private family photographs off the shared AI provider entirely — a photograph of somebody's child never leaves the country to be classified.

The trade being made, stated: nothing scans this surface, so nothing catches a bad photo before a group sees it. The mitigation is that the audience is a group whose members were each individually invited by an existing member, and that any member can delete or report. This is family, supervised, not platform, moderated.

6. Deletion

7. Risks and mitigations

Risk Who is harmed Mitigation Structural or conventional Residual
A photograph's EXIF discloses a child's home address the child Full re-encode from decoded pixels; no metadata copied Structural — there is no code path that copies EXIF —
A signed URL is shared and outlives the sharer's intent subjects of the photo 15-minute expiry; private bucket Structural A URL is usable for 15 minutes by whoever holds it. Accepted
An operator opens the bucket — a SEALED photo subjects, mostly children The bytes are XChaCha20-Poly1305 ciphertext under a per-photo key, wrapped under a group key that exists only on members' devices. There is no decrypt endpoint and no backend function holds a key Structural The operator still learns that a photo exists, when, and who added it (metadata is not hidden)
An operator opens the bucket — a photo added before 9 September 2026 subjects, mostly children Nothing, until an admin's device runs the history job over that group Conventional — it depends on somebody opening the app and letting a job finish Accepted. The policy claim is dated; the wall shows a not-yet-encrypted notice while history remains, and no encryption notice once every photo is sealed
Every device in a group loses its keys, and the photographs become unreadable to the group too the group The group key is wrapped for every member device, another member re-wraps for a new one, and a 12-word recovery phrase is the last resort Structural Accepted, and it is the price of the row above. If every device and the phrase are lost the photographs are gone, permanently — there is no operator override, by construction
A copy of a child's photograph outlives the family's control of it a child in the photo Provenance link; "also remove the copies" on by default; the tombstone is durable and re-applied; a destruction request is honoured by the poster or a group admin Provenance and the tombstone are structural; the honouring is conventional Accepted and disclosed — the copy button says the copy outlives the event group
A group admin uses the report queue to read reports about their own conduct the reporter The reporter's name is never selected by the admin-facing function, and an admin never sees, keeps or is paged about a report on a photo they posted Structural (a select list and an uploader filter in three functions, pinned by database tests) An admin who is the subject of a report in some other way (they are in the photo) still sees it. When the poster is the only admin a report reaches nobody, and the viewer says so instead of offering Report
An admin learns from the push who reported a photo, or what it shows the reporter; the photo's subjects The push names the group and a fixed line; the claim takes the photo and the caller, and never reads reported_by or a caption Structural (a fixed body, pinned by a source check and a delivery test) In a group with one ordinary member, "someone" is not anonymous. Accepted: the same is true of the list, and of any report in a group that small
A member of the group is no longer welcome and still sees the photos subjects Removal from the group ends access immediately, both at the row and at the object Structural —
A photo of a child posted by one parent is seen by people the other parent did not choose the child Group membership is the boundary; membership is granted by an existing member — Accepted. The remedy is who is in the group
A harmful photo sits on the wall unscanned anyone in the group Delete (by an admin or the poster), report to the group's admins (listed, marked and pushed), and an invite-only audience Conventional Accepted deliberately. §5
A like or reaction display returns, in any form — a heart, a name, a count children especially Likes were removed for every member on 8 September 2026; a standing source scan refuses the identifiers a reinstated feature would need, and the viewer's own test asserts no "Liked by" renders Conventional — test-pinned Re-adding any display of how others engaged is a change of the app's legal position, not a feature; see the ban self-assessment
A caption names a child and their school the child Nothing — it is free text — Accepted
A photo is un-deletable because its objects were orphaned the group Objects-before-row ordering Structural (an ordering, held by a comment and a code path) —

8. Best interests of the child (exposure draft s 10)

The children on this wall did not post the photographs and did not consent to them. That is the defining fact of the surface, and it is not fixable — a family photo album has always worked this way. What can be decided is how far the app amplifies it.

What the design gets right for the child:

What it costs the child: a permanent, timestamped, captioned record of their childhood in a database, currently readable by an operator readable by an operator for as long as any of it predates 9 September 2026, created by adults. Deletion is available to the uploader and to group admins — not to the subject, who is a child and has no account.

And one thing encryption takes AWAY from the child, which has to be said rather than buried. Before 9 September 2026, a report about a photograph of a child could in principle have been reviewed by us. It cannot now — we cannot see the photograph, and building anything that let us would be the decrypt oracle the whole design forbids. So the only people who can act on a report are the group's own admins, who are the child's own family or the family who invited them. For the ordinary case that is better (it is faster, and fewer adults see the image). For the case where the admin is the problem, there is no route left inside the app, and our destruction-request procedure must say so in plain words rather than implying an operator search. An honest limitation stated is better than a capability implied — but it is a genuine loss, and we are accepting it in exchange for taking us out of the operator row above.

Where interests diverge, and who won. The parents' interest in a shared family album won on collection; there is no version of this feature that does not. The child's interest won on everything downstream: no analysis, no metrics, no audience beyond the group, no border crossing, and photos first in the encryption sequence. That is roughly the right split, and the place it is weakest is deletion — a fourteen-year-old who would rather a photograph of them at six were not on the family wall has to ask a parent. That is the correct answer for a family, and it should be a conscious one rather than an accident; when child accounts ship, whether a child can request removal of a photograph of themselves is a design question worth asking explicitly.

9. Residual risk we accept

Interrupted encryption of older photographs (reviewed 18 October 2026)

A phone checks that the encrypted copies can be opened before removing the older readable copies. If the connection fails, the readable copies remain and the wall continues to offer a retry. Temporary readable files on the phone are removed when the work finishes, on sign-out or leaving the group, and when the app next opens after an interruption.

A lost response can leave an extra encrypted copy in storage. The phone keeps it because it cannot yet know whether the saved photograph points to it. This can retain encrypted bytes beyond the ordinary removal path; it does not create another readable copy on our server.

10. Review trigger

11. Sources

In repo, verified 2026-09-07: supabase/migrations/20260816003714_group_photo_albums.sql (all four tables, the group_media bucket, the three storage policies, the realtime publication); lib/photos.ts (processPhoto, PHOTO_VARIANTS, the path builders, the objects-before-row note); lib/photoAlbums.ts; lib/secureImage.ts; components/Common/SecureImage.tsx; components/Social/PhotoTile.tsx; components/Social/PhotoViewer.tsx; store/slices/createPhotosSlice.ts (deletePhoto, reportPhoto, subscribeToGroupPhotos); our internal engineering rules on the photo wall, on authorising the avatars bucket by path, and on treating erasure as proven only once it has been tried; docs/privacy_posture.md §§1, 4.2, 4.3.

stripExifFromBase64Jpeg was searched for repo-wide and does not exist; it was deleted on 2026-09-01.

Added for the 2026-09-09 revision: supabase/migrations/20260909*_ws12_photos_sealed.sql (the columns, the copy RPC, the tombstone table, the admin report function, the two replaced restrictive policies and the bucket function); lib/crypto/stream.ts and lib/crypto/bytes.ts (chunked sealing); lib/photoCrypto.ts, lib/photoCache.ts, lib/photoHistoryJob.ts, lib/photoExport.ts; store/slices/createPhotosSlice.ts; components/Common/SecureImage.tsx; supabase/functions/sweep-photo-copies/, supabase/functions/delete-account/, supabase/functions/export-my-data/; docs/encryption_plan.md §§1, 3, 5, 9; supabase/migrations/20260908035756_ws7_event_groups.sql §4 (the policies replaced here); __tests__/photo_sealing_rls.test.ts.

Not re-read for this document: the Children's Code exposure draft itself; the s 5 Note 1 reference comes from docs/privacy_posture.md §4.2.