← 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:
- No EXIF. Every variant is a freshly re-encoded JPEG written from decoded pixels on the
device. Nothing is copied across, so there is no GPS coordinate, no camera serial, no capture
timestamp beyond the
taken_atthe app records itself, and no IPTC block. The re-encode is the strip. An earlier, since-deleted metadata scanner used to be compared against for this; it skipped one metadata block only, preserving another that can itself carry location, and must not come back. - 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 (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:
- The same pipeline, byte for byte. The picked image goes through the existing path: decoded once, re-encoded into the three bounded variants (which is the metadata strip — no EXIF, no location survives), sealed on the device under the group's key, uploaded to the same private bucket under the same rules. No parallel media stack, no "original quality" shortcut, no bypass of the no-key-no-upload rule: a device that has not been admitted to the group's key cannot attach a photo from chat any more than it can upload one on the wall, and the composer says so with the same notice the wall uses.
- Photos added to group chat are NOT automatically added to the group wall. Attaching a photo
in chat writes the row to
group_chat_photos. It does not appear on the group's photo wall, albums, or home rail. Importantly, because existing app bundles in the wild select fromgroup_photosfor their wall display, storing unpromoted chat photos ingroup_chat_photosguarantees zero leakage of chat-only photos onto older clients' walls across the OTA rollout window. - Promotion to the Wall. When a member presses the Album button on a chat photo, a modal
offers album selection (defaulting to the current month bucket) and an optional caption.
Upon confirmation,
save_chat_photo_to_wallatomically inserts a row intogroup_photosreferencing the same storage prefix, resealing the caption under the photo's existing key. This write confirms withundoNotice, allowing the member to reverse the promotion within the toast window. - The caption and the message are each sealed as they already are — the caption under the photo's own key, the chat message under the group's chat sealing — and a failed upload returns the member's words and selection to the composer rather than losing either.
- Nothing new reaches a model. No image, caption, album name, or comment reaches a model. There is no AI moderation on this surface, and no reaction, like, or view count appears on a photo in chat or on the wall.
- A child member can attach a photo the same as they can post a message.
Photo Comments (added 24 September 2026)
Members can add comments to photos on the wall:
- Sealed on the device (Tier 1). Comments are stored in
group_photo_comments. Each comment is encrypted with a fresh random per-item AES-GCM data key, wrapped under the group key at its current version (wrapped_data_key,key_version). The database holdscontent_sealed; plaintextcontentis a placeholder (Encrypted comment). - No Key, No Comment. A device without the group key cannot comment; the write is refused client-side. If a device receives a comment it cannot open, it renders a locked placeholder.
- Group Agreement Enforced in Database. A composite unique constraint on
group_photos (id, group_id)and a composite foreign key ongroup_photo_comments (photo_id, group_id)structurally prevent a comment withgroup_id = Afrom pointing to a photo ingroup_id = B. ABEFORE INSERT OR UPDATEtrigger additionally enforces this match. - No Feedback Metrics or Counts. Under Australia's under-16 rule (s 4A(5)(a)), no comment count, badge, or engagement metric is rendered anywhere on the photo tile, viewer, or grid.
- Share to Chat. Beside each comment, an arrow button allows sharing the comment text and photo link into the group chat, routing conversation back into the main household messaging stream.
- Data Export & Erasure.
group_photo_commentsis included in user data exports and account deletion purges. Deleting a photo cascades to its comments.
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:
- The photograph is the record; the message is a reference to it. Deleting the message
removes the message and leaves the photograph on the wall (if promoted) or in
group_chat_photos. Deleting the photograph — from the wall, or by the uploader or an admin under §4's existing rule — removes the image everywhere, and the message it was attached to renders as a photo that is no longer there rather than as a broken tile or a silently blank bubble. A deleted photograph must not leave a readable copy inside a chat message. - The precise lifecycle, because "the reference goes away" can mean three different things.
The message row is not deleted when the photograph is — a member's words are theirs and do
not vanish because an image did. What the message carries is two separate things: a link to
the photo row, which is
ON DELETE SET NULLso it empties the instant the photograph is deleted and can never resolve to anything afterwards; and a flag that this message was sent with a photo attached, which is set at write time and is not cleared by the deletion. The combination is what makes the absence honest and renderable: flag set, link empty, so the bubble knows to say the photo was removed rather than rendering a bare message as though nothing had been attached. The null is the structural guarantee — once the photo row is gone there is no identifier left on the message that could be used to fetch anything, so a deleted photograph cannot be read back through chat even by a client that tried. - That flag is a marker, not a tombstone, and the difference is what it holds. It records only that an attachment existed — no storage path, no caption, no photo id, no deletion time, nothing about who deleted it or when. So it adds no retention question of its own, discloses nothing after the fact, and nothing in it outlives the erasure paths §6 already describes.
- Retention is the wall's, unchanged. A photo attached from chat is retained, swept and erased exactly as a photo added on the wall — the same account-erasure sweep, the same objects-before-row ordering, the same copy-removal tombstones if it is ever copied. The chat door adds no second retention rule, which is the point of it being a door rather than a room.
- A partly-failed attach leaves nothing behind, and a retry does not duplicate. Uploading the variants, writing the photo row and writing the message are steps that can fail independently. The rule: the message is written last, and a failure at any earlier step leaves no message, returns the member's words and their selection to the composer, and cleans up whatever it had already written — the same objects-before-row discipline §6 describes, applied forwards. A retry reuses the identity the first attempt minted rather than starting again, so pressing send twice on a flaky connection produces one photograph and one message, not two of either. Without that, the visible symptom is a duplicated photograph of somebody's child on the wall, which is a privacy outcome and not only an untidy one.
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
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 (the design and our three conditions are set out in the cross-group events assessment, §8, reassessed 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. Restrictive policies introduced on 8 September 2026 initially refused a
group_photosorgroup_photo_albumsrow in an event group outright; they were 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 our 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. A dedicated database test proves both directions: an unsealed row is refused in an event group and a sealed one is admitted.
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:
- Admins see it in two places. Manage Group → Reported photos lists the group's open reports,
one row per photo (a thumbnail and the day it was reported, in group time), with Keep and
Delete. On the wall, a reported photo carries a flag marker on its tile and a line in the
viewer ("Someone reported this photo.") with the same two choices. Both read
photo_reports_for_group; no report count is ever shown. - Admins are told once per photo. A push through
send-targeted-notice(kindphoto_report) to the group's active adult owners and admins who left "Tell me when a photo is reported" on (users.photo_report_push, on by default, in Manage Group and Settings → Advanced). Its title is the group's name and its body is fixed: "Someone reported a photo in this group." — no reporter, no caption, no photo, at every privacy level. The ledger key is per photo, so a second report on the same photo pages nobody; Keep clears the ledger, so a later report on a kept photo pages again. Tapping it opens Manage Group. Owner decision, 10 October - Keep stamps
dismissed_aton the photo's open reports (dismiss_photo_reports, admins only); Delete is the ordinary photo delete, and the reports go with the photo by cascade. - The person who posted the photo is never told, even when they are an admin. Showing a
poster that others reported their photo is a report of how others engaged with what they
posted, which is the feedback-feature test in the
ban self-assessment §5.3. So
photo_reports_for_groupleaves out photos the caller uploaded,dismiss_photo_reportsrefuses them, and the push claim leaves the uploader out. Structural, in all three functions, pinned by database tests. When the poster is the group's only admin a report would reach nobody, so the Report alert says that and offers no Report button: "The group’s only admin posted this, so a report wouldn’t reach anyone. You could ask them to delete it." - The reporter's confirmation says: "This tells the group’s admins, so they can look at it and delete it. They won’t see who reported it, and whoever posted it won’t be told. We don’t review reports ourselves." A copy test refuses "we can’t see" and a promised review by us.
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
- 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. Deleting a photo always removes the storage objects 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 device in the group's timezone 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 scheduled sweep rather than by the deleting device. 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. A scheduled sweep then 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: closing an account files 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 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:
- 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.
- We are now outside the boundary for anything sealed, since 9 September 2026. 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 (see the cross-group events assessment, §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 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
Plaintext at rest.Plaintext at rest for the HISTORY only, since 9 September 2026. 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 policy claim is dated for exactly this reason; the wall only shows the not-yet-encrypted notice.- 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 (a decision made on 7 September 2026), and the remedy if a tester reports a hot phone is a change confined to the encryption code 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 the sweep next runs. A sweep that is never wired up (its configuration 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 likes removal is client-side. The dormant tables and the
like_countcolumn remain, and an active member driving the API directly could still write a like row nothing will ever display. The absence in the app is held by tests and a source scan, not by the database. - A child cannot ask for a photograph of themselves to be removed. Accepted for now; revisit with child accounts.
- This assessment is late.
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
- 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 on 9 September 2026 and is assessed here and in the cross-group events assessment, §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 a backend 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 the re-encode.
- The chat-composer attachment (§3) being built — this document is revised against the
code, and the claims to re-check as shipped facts are: same pipeline, photo on the wall and
said so, no chat-only visibility scope, no attach without the group key, no engagement
affordance on a photo in chat, and the lifecycle rules — deleting the photograph empties the
message's link to it (
ON DELETE SET NULL) while leaving the message and its attachment marker, so the removal is renderable and no identifier survives to fetch a deleted image; a partly-failed attach leaves nothing behind; and a retry cannot duplicate the photograph. - Any design that gives a chat-attached photo its own copy, its own retention, or its own visibility — that is the second room this door was specifically designed not to open, and it re-opens every question in §§3–6 for a second class of image.
- The bucket's policies, or the bucket ceasing to be private.
- The encryption work landing for every existing photograph (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); 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.