← 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:
- No EXIF. Every variant is a freshly re-encoded JPEG written from decoded pixels by
expo-image-manipulatorinlib/photos.ts. Nothing is copied across, so there is no GPS coordinate, no camera serial, no capture timestamp beyond thetaken_atthe app records itself, and no IPTC block. The re-encode is the strip. The byte-level base64 marker scanner this used to be compared against was deleted on 2026-09-01 and must not come back: it skipped APP1 only, preserving APP13/IPTC, which can itself carry location. - No face detection, no tagging, no album suggestions, no memories, no AI of any kind on this surface. §5 explains why.
- No location, from any source.
- No numeric like tally rendered anywhere (§4).
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>.enc — ciphertext 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.ts → generateItemKey); 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
group_photosSELECT, INSERT and UPDATE are gated onis_active_group_member(group_id). DELETE additionally requiresuploaded_by = auth.uid() or is_group_admin(group_id).Storage objects are authorised by parsing their own name. The path is untrusted input a policy parses: SELECT, INSERT and DELETE on the bucket all require
can_access_group_media_object(name), which re-checks membership independently of the row. Two layers, deliberately.There is no UPDATE policy on the bucket, deliberately. Every photo owns a fresh uuid and nothing is ever replaced in place, which is what makes a cache key built from the storage path safe to hold forever.
Photos never cross a group boundary.A photo can now be COPIED across one, and only one, kind of boundary: out of an event group, into a group the copier is already an active member of (WS12; the design and the owner's three conditions are incross-group-events.md§8, re-assessed on the same day as this revision). What crosses is not the row and not the object: the copying device decrypts, re-seals under its OWN group's key and uploads a new object under its own group's path. The server moves nothing and reads nothing. Two properties follow that are worth stating because they are what make it survivable:- No policy is widened. Every read in the flow is the copier's own — they are a member of
the event group, so they can already see the photo, and a member of the target group, so
they can already add a photo to it.
copy_group_photois SECURITY DEFINER for one reason only: to check that thesource_photo_idwritten onto the new row is a photo the caller can actually see, so provenance is a fact rather than a claim a client can forge. - The copy is an ordinary photo of the target group from that moment on. It has that group's key version, that group's members, that group's deletion rules — plus a link back.
- No policy is widened. Every read in the flow is the copier's own — they are a member of
the event group, so they can already see the photo, and a member of the target group, so
they can already add a photo to it.
An event group may hold photographs, and only sealed ones. WS7's restrictive policies refused
group_photosandgroup_photo_albumsin an event group outright; they are replaced by policies that admit a row only whenencrypted = true, and the bucket function admits an event group's objects only under thesealed/prefix. So the owner's condition — no event group ever holds an unencrypted photograph of another family's child — is held by the database rather than by the client remembering to seal.__tests__/photo_sealing_rls.test.tsproves both directions: an unsealed row is refused in an event group and a sealed one is admitted.
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:
group_photos.like_countstill 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.- 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
- Objects are deleted before the row. The row is what the delete policy consults to decide
whether this member may delete at all, so removing it first would strand three objects in the
bucket with nothing left to authorise cleaning them up.
deletePhotoinstore/slices/createPhotosSlice.tscallsremovePhotoObjects()first. - Deleting an album does not delete its photos.
album_idisON DELETE SET NULL, so they fall back to their month grouping. Months are derived on the client in the group's timezone (lib/photoAlbums.ts) and are deliberately not rows — materialising them would mean creating them on upload, garbage-collecting them when empty, and rewriting them when a group changes timezone. - Account erasure sweeps storage recursively before the cascade removes the rows, because after a cascade there is no row left to authorise a delete with. Every step is "make sure this is absent", so a partial failure is safely repeatable. Evidenced in the off-provider erasure ledger.
- Deleting a photo somebody has copied offers to remove the copies, and that offer is kept by
a sweep rather than by the deleting device (WS12). The device cannot do it: the copy's
objects live under another group's path, which
can_access_group_media_objectwill not let it touch, and that refusal is correct. Sorequest_photo_copy_removalwrites a tombstone — a durablegroup_photo_copy_removalsrow per copy, pluscopy_removed_aton the copy itself, which a restrictive SELECT policy reads so the copy stops being visible to anybody in the same statement.sweep-photo-copiesthen removes the objects and only then the rows, which is the objects-before-rows ordering honoured where it can actually be honoured. - Account erasure removes copies of that person's uploads the same way:
delete-accountfiles the same tombstones for every photo the departing account uploaded, and sweeps those prefixes in the same pass that sweeps their own group's. Prompt, and repeatable. - The tombstone is not stored inside the thing it outlives.
group_photo_copy_removalshas no foreign key togroup_photosin either direction, deliberately: a request that cascaded away with the copy it names would be a tombstone inside the grave, and the sweep's retry after a lost response (the copy gone, the request known) must still succeed. Same shape and same reason asbaby_history_deletions. - What deletion cannot reach, stated plainly: a copy that has itself been copied onward carries the copy's id as its source, not the original's, so one request removes one generation. In practice a copy lives on a group wall where copying out again is not offered (only an event group's wall shows the copy button), so the second generation does not arise today — but nothing in the schema prevents it, and a future surface that offered it would need this walked recursively.
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:
- The audience is the smallest one that makes the feature work. One group, invite-only, no cross-group surface, no public link, no discovery.
- Nothing is inferred from a child's face. No detection, no tagging, no clustering, no memories, no AI. A photograph of a child is stored and shown; it is never analysed. This is the single most consequential choice on the surface, and it is a choice — every mainstream photo product does the opposite.
- No engagement metric on a child's image. The tally is gone. A child growing up inside a family app should not learn that photographs of them score.
- Nothing leaves the country. No model, no classifier, no third party sees these images.
- EXIF is gone, so a photograph taken in a back garden does not carry the back garden's coordinates.
- The operator is now outside the boundary for anything sealed (WS12). The strongest thing this surface can do for a child whose photograph they did not consent to is to shrink the set of adults who can look at it; encryption removes us from that set, which is a bigger change than any of the display decisions above.
- A child is never in an event group (WS7,
cross-group-events.md§2), so the wall a copy can be taken FROM never holds a child's own account, and the wall it lands ON is one whose children could already see anything their own parent posted.
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
Plaintext at rest.Plaintext at rest for the HISTORY only (WS12). New photographs are sealed; everything already in the bucket stays readable to an operator until an admin's device finishes the job for that group, and nothing forces that to happen. The claim in the app and in the policy is dated for exactly this reason.- The whole read and write path is unverified on a real handset. It compiles, and every rule
in it is unit-pinned, but no phone has sealed a photograph, uploaded it, decrypted it into the
cache and rendered it. Grid performance in particular is discovered in use — there is no
benchmark gate any more (encryption plan §9, owner decision 2026-09-07), and the remedy if a
tester reports a hot phone is one file (
lib/crypto/backend.ts) and one native build. - Losing every device and the recovery phrase loses the photographs. By construction.
- The copy tombstone depends on a scheduled sweep running. The copy stops being visible
the moment the tombstone is written, which is structural; the bytes go when
sweep-photo-copiesnext runs. A sweep that is never wired up (its three Vault rows missing) logs a warning and leaves objects nobody can find a path to. - One generation of copies. §6.
- No AI moderation. Deliberate. §5.
- Signed URLs are bearer credentials for 15 minutes.
- The like-count absence is conventional. A comment and a decision, not a constraint, and
like_countis still a column. A test pinning "no surface renders a like tally" is recommended. - A child cannot ask for a photograph of themselves to be removed. Accepted for now; revisit with child accounts.
- This assessment is late.
10. Review trigger
- Anything that analyses an image — face detection, tagging, suggested albums, memories, search over photos, on-device or otherwise. This assessment's best-interests reasoning turns on that absence.
- Any surface that renders a like tally, in any phrasing.
- Any surface that shows a photo outside its group. The event-group wall shipped with WS12
and is assessed here and in
cross-group-events.md§8; a SECOND such surface — copying between standing groups, a copy of a copy, a share link — is a new assessment, not an extension of this one. - Anything that would let us read a sealed photograph: a decrypt endpoint, a key held by an edge function, a moderation path that "just needs the plaintext", a report queue with a service-role reader. Each of these reverses the central finding of this revision.
- Any new column on
group_photosorgroup_photo_albumsthat holds words somebody typed, which must be sealed like the caption or explained here as state. - Any change to the upload pipeline that could preserve metadata: a new variant, a "keep
original quality" option, a video path, a direct-upload shortcut that skips
expo-image-manipulator. - The bucket's policies, or the bucket ceasing to be private.
- #223 landing (the plaintext claim changes); child accounts shipping.
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.md → The 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.