← All privacy impact assessments

PIA: the group photo wall

Status: Revised 2026-09-09 for WS12 — photographs, captions and album names are now sealed on the device before they are uploaded, and the event-group wall named in §10 as a review trigger is built. The original document was retrospective, written against the code as it stood on 2026-09-07; the sections below say plainly which claims changed and which did not. Written: 2026-09-07 · Revised: 2026-09-09 (WS12) · Issues: #221 (register), #224 (the like count, dropped 2026-09-07), #223 (encryption), #228 (destruction requests) 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 WS12 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 — which is why the wall says encrypted since <date> rather than encrypted.


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_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 (WS12), the event-group wall and the copy flow (WS12, assessed with cross-group-events.md §8), and the history job that seals what is already in the bucket.

Out of scope: dish photos on public meal-plan reviews, which are a different surface with a different threat model (visible across groups, AI-moderated, and since #257 written only by the moderate-photo edge function); user and baby avatars, which live in the avatars bucket and are not sealed by WS12.

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) — attributed the liker's no no
group_photos.like_count — an integer column maintained by trigger no no
group_photo_reports (photo_id, reported_by, reason) the reporter's yes — the reason
group_photos.key_version, wrapped_data_key, encrypted (WS12) no — a wrapped key is inert without a device key no
group_photos.caption_sealed, group_photo_albums.name_sealed (WS12) the writer's words often about a child ciphertext the plaintext is
group_photos.source_photo_id, source_group_id, source_label_sealed (WS12) provenance of a copy the label is ciphertext; the two ids are not the label is
group_photo_copy_removals (WS12) 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 (WS12) 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>.encciphertext only never
The photograph, not yet sealed (uploaded before WS12) 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 (lib/crypto/groupKeys.tsgenerateItemKey); 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 (lib/photoCrypto.ts), which is what keeps a full-size photograph out of memory as one buffer — the trap stripExifFromBase64Jpeg fell into and was deleted for. 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. expo-secure-store on web is export default {} — no key storage at all — so groupKeyStatus is unsupported there 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 64pt tile is what makes the wall feel slow on cellular and is invisible on a simulator over 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 (lib/secureImage.ts, 15-minute expiry, in-memory cache and in-flight dedupe) consumed by components/Common/SecureImage.tsx.

Nothing here is encrypted at rest. Changed by WS12, 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 claim in the app and in the policy 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. Unchanged by WS12 and unchanged by the copy flow.

4. Who can see it, and what is displayed

Likes are attributed and are never rendered as a number. PhotoTile shows a bare heart when a photo has any like; PhotoViewer renders Liked by … naming the likers. The count was dropped on 2026-09-07 (#224) as the cheap hedge on the under-16 exemption, and "and 2 others" is a number too, so it must not come back in any phrasing.

Two honest notes about that change, because it is the one most likely to be described wrongly:

  1. group_photos.like_count still exists as a column, maintained by trigger, and the grid badge reads it (> 0). No surface renders it as a number, which is what the decision was about, but the column is there and a future component could print it.
  2. Attributed likes remain a "feedback feature" within Rules s 4A(5)(a) — information about the extent to which other end-users have engaged with material the end-user posted. Naming three likers conveys extent as surely as printing "3". Dropping the tally was worthwhile and it did not take the app out of that condition. See 2026-09-ban-self-assessment.md §5.3.

Likes are attributed and read receipts are not, and that inconsistency is deliberate. A read cursor is passive surveillance; tapping a heart is an act addressed to the person who posted. Do not "make them consistent".

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 NOW GOES TO THAT GROUP'S ADMINS, NOT TO US, AND THAT IS A CONSEQUENCE OF THE ENCRYPTION RATHER THAN A PREFERENCE. Once the photograph is sealed we cannot look at it, so "sends the photo to us to review" would be a promise of a review that cannot happen. The honest arrangement is the one that can: the group's own admins can see the report and the photograph, and they can delete it. No service-role reader exists in any edge function, and none may be added — that would be a decrypt path built for us, which invariant §5.3 of docs/encryption_plan.md forbids outright.

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 WS12 intact, and it is now held by a function's select list rather than by a policy.

There is no AI moderation on this surface, unlike dish photos, and that is a decision. Dish photos are visible across every group, so they are checked by moderate-photo before storage. These are visible only inside one invite-only group. Delete plus report is the whole safety story, and it keeps a group's private family photographs off the shared OpenRouter key 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 edge function holds a key Structural The operator still learns that a photo exists, when, and who added it (§7 of the encryption plan: metadata is not hidden)
An operator opens the bucket — a photo added before WS12 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, and dated in the copy. This is the reason the claim is "encrypted since ⟨date⟩" rather than "encrypted"
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 (WS11) 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 Structural (a select list, pinned by a db test) An admin who is the subject of a report still sees the report. Accepted: they are also the only person who can act on it
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 and report; invite-only audience Conventional Accepted deliberately. §5
The like display becomes a count again children especially #224; the decision recorded in docs/privacy_posture.md §4.3 and cited in the components' own comments Conventional — a code comment Accepted. A test pinning the absence is the cheap upgrade and is recommended
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 WS12, 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 WS12, 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 #228's 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 the owner is 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 accepted by the owner

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); AGENTS.mdThe Photo Wall, The Avatars Bucket Authorises By PATH, Erasure Has To Be Tried Before It Can Be Claimed; 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.