← All privacy impact assessments
PIA: cross-group events — RSVP invitations and event groups
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.
Status: Built. RSVP invitations and event groups both shipped 8 September 2026; the event-group photo wall shipped 9 September 2026. Written: 7 September 2026 · Last revised: 30 October 2026 (deleting a group); 14 October 2026 Nothing here is legal advice. Children's Code citations are to the exposure draft.
This is the first feature in GroupApp's history that would let two groups touch. That is the whole reason it needs an assessment before a schema: the absence of any cross-group surface for individuals is doing real work in the ban self-assessment, and it is the property this feature spends.
Amended 14 October 2026, before the code: an event group's history reaches everyone who joins it. From the app update released after 14 October 2026, members' phones send a group's earlier keys to every person's phone in it, so a member can read what was written before they joined (the key architecture assessment, §3.7). Event groups follow the same rules as every group. Their first keys are made after the change that gave keys a check value, so sharing is on from an event group's first key (made while its host is the only member), with no notice in a group that has nobody else to tell; each adult joining is told before they join (under-18s still cannot join an event group, §6). This means an adult from another family who joins an event group late can read its earlier chat and see its earlier photos, which can include other families' children. We accept that (§13): it is what lets a late joiner catch up on the arrangements, and the photos were posted to everyone in the event.
1. Purpose and scope
What it is for. A birthday party. One family is hosting, five others are coming, and today the app has nothing for it — the invitation goes out by text message and the RSVPs come back in six different threads. The feature is a calendar invitation that crosses a group boundary and comes back answered, plus (Step B) a temporary group for the coordination a bigger event needs, and (Step C) persistent connections and direct attendance for own groups.
Step A — RSVP invitations. A host issues a code for one of their events. An adult in another group redeems it, picks which of their groups' calendars it lands on, and answers. The event is mirrored into that group as an ordinary calendar row.
Step B — event groups. groups.kind = 'event': a temporary group with chat, a bring
list, the one event, and members. Nothing else. It goes read-only on a date, is announced as
going read-only on that date, and is kept as a record of the event until an admin of the host
group deletes it. and is deleted. — the draft said deleted on a timer; see §1B.
Step C — connected groups and direct attendance. Added 24 September 2026. Bilateral connections between groups, targeted event invitations, and direct attendance for an adult's other groups. Assessed in detail in the connected groups assessment.
1B. Step B as built (2026-09-08)
We changed the lifecycle before this was built, because of the app's legal position.
The draft (§7, below, as first written) said an event group is deleted thirty days
after it goes read-only, chat and tasks included, and treated that as a privacy virtue. It is
also, almost verbatim, the time-limited-content limb of the logged-in-feature definition in
the Online Safety (Age-Restricted Social Media Platforms) Rules 2025 s 4A(6) — "enables viewing
of material only within a limited period after it has been posted" — and this app's whole position
under the under-16 ban is that it satisfies NO s 4A feature. A deletion timer on a chat would have
put the app back inside the condition through a feature nobody thought of as social at all.
So: an event group archives seven days after its event, and is never deleted by anything but an
explicit act. No delete_after column, no deletion sweep, and no copy that promises otherwise.
The ban self-assessment §5.4 records the same decision from
the other side.
This is the second time this register has changed the product rather than described it (the first was the feedback features, §5.3 there), and it is worth noticing that both times the change came from taking the statute's own words seriously against a design that read well.
| Draft said | Built | Why |
|---|---|---|
groups.kind in ('group', 'event') |
('standing', 'event') |
"Group" as a value of a column on groups says nothing; our later decision that every group is called a group, not a household, made group the word for every group, so the kinds needed a word that distinguishes them |
delete_after, and a sweep that "archives and deletes" |
No such column. sweep_event_groups sets archived_at and has no delete statement in it |
The s 4A(6) argument above |
| Read-only "enforced by a restrictive policy on writes, not by the client" | Built exactly so — as restrictive INSERT/UPDATE policies on group_messages, group_tasks, group_schedule_events and task_completions, keyed on is_archived_group(group_id). Reads untouched |
An archive nobody could read would be a deletion with extra steps |
"One as restrictive policy per tool table" |
29 tables, plus a join-predicate policy for group_photo_reports — the baby domain (11), meals (5), games (8), voice (2), photos (2) and chat_sessions. Proved by an adult member of an event group attempting a representative insert into each bucket, AND by the same insert succeeding in their own standing group |
The second half is what makes it a test of the KIND rather than of some unrelated refusal |
| — | A restrictive policy is NOT enough for the baby domain, and BEFORE INSERT TRIGGERS were added for it. Nothing in this schema has force row level security, and every SECURITY DEFINER function is owned by the table owner — so log_baby_event, log_feed, log_past_feed and request_baby_history_deletion skip a policy entirely. Triggers on group_babies, baby_logs, baby_feed_sessions and baby_history_deletions fire for the group owner too |
This is the structural-vs-conventional distinction this assessment exists to catch, found by asking the question rather than by a failure. For the rest of the list the gap is academic — an RPC would have to be handed an event group's id by hand to write a row nothing renders — and it is stated rather than closed, which is the honest position |
| — | The free-collection triggers were patched, not policed. Creating a group fires an after insert trigger that grants every free collection; SECURITY DEFINER, so the policy would not have stopped it, and an event group would have silently held the whole meal catalogue. Three body-only replacements add where kind = 'standing' |
A restrictive policy that a trigger walks past is the appearance of enforcement |
| — | can_access_group_media_object gained the same predicate. The photo BUCKET is authorised by parsing the object's own name, so a policy on group_photos would have left the objects writable |
Our rule that a photo bucket is authorised by the object's own path, not by its metadata row: the metadata row and the object are two separate write paths, and both need guarding |
| — | The four new groups columns are refused to every client write by a trigger under a transaction-local arm, the same shape already used for the RSVP mirror columns. Active members can update groups would otherwise let any member flip an event group to standing and re-enable every tool, or clear archived_at and write into an archive |
The permissive UPDATE policy on groups is deliberately wide (any member may rename a group); the guard is per-column |
| Joining is "the existing invite code and pending-then-approve ceremony, unchanged" | Unchanged, with two refusals added inside request_join_group (a body-only replacement, since the earlier migration may not be edited): an under-18 cannot ask to join an event group, and an archived event group admits nobody. Neither is charged to the guess ledger — the caller was holding a real code |
The §2 rule, server-side, proved with an under-18 account. The check is scoped to event groups: a child joining their own family's group is untouched |
| "Approval also creates the mirrored event in the joiner's chosen group" | group_members.event_home_group_id records the choice at join time (set_event_group_home, the caller's own row only); an after update of status trigger creates the mirror when they are approved. Null means no mirror — they still see the event inside the event group |
Which calendar is the joiner's decision and nobody else's, and a pending member has asked rather than been let in. An event landing on a family's calendar before anyone said yes would be the invitation letting itself in |
| — | The founding group gets a mirror too, at creation | Without it the household hosting the party is the one household with no calendar entry for it |
| — | The invitation an event group carries has a random code that is never surfaced. create_event_invitation (correctly) refuses an event that lives in a non-standing group, so the row is written directly; it exists as the vehicle the mirror machinery acts on, and the code guests type is the group's own reusable invite |
Two codes for one party would be a worse feature than the one it replaces |
| Deletion by "an explicit admin action" | delete_event_group requires is_group_admin(parent_group_id) — an owner or admin of the HOST group, deliberately not the event group's own owner, who may be an ordinary member of the host group. A restrictive DELETE policy on groups says the same thing to the direct client path |
The space belongs to the group that asked for it |
"No group-specific assistant … our assistant server re-reads groups.kind on every request" |
Built, at both doors and in three places: a function called buildAgentContext calls is_standing_group beside the membership check and fails closed, on both the assistant's text endpoint and its voice ask endpoint; voice's per-turn groups read carries kind and refuses before dispatch, which is what covers reading the group's chat back aloud; and the voice pairing endpoint refuses too. A source scan of our assistant server's own code is what proves it |
Reading a group's chat back aloud is the one thing that would otherwise read another family's party chat to whoever is in the room. It never reaches buildAgentContext, so a single gate there would have missed it |
| — | The dashboard brief is off for the kind, by passing a null group id | It is a model writing about the group, which is what "no group-specific assistant" means |
| — | voice_grants is on the restrictive list, so a grant naming an event group cannot exist at all |
Our rule that a voice grant names the group it applies to, so a grant naming a group nobody may see is not a smaller version of the problem — it is the problem |
Built later, and re-assessed (9 September 2026): the event-group photo wall (§8). Until the
photo-encryption work shipped, group_photo_albums / group_photos were on the restrictive list
precisely so it could not arrive by accident; an event group now admits a photograph only when
it is sealed on the device (encrypted = true).
In scope: everything that crosses the boundary, in both directions; who may cross it; what each side learns about the other; the lifecycle and deletion of an event group; and a re-run of the ban self-assessment, because this feature changes the answer's inputs.
Out of scope: the sealing of photographs itself, which the photo wall assessment covers; the event-group photo wall is designed in §8 and was re-assessed there as built on 9 September 2026, with the photo-encryption work; the child-account design itself (see the child-accounts assessment); the universal invite link, which is a transport decision, not this feature.
1C. Revision, 25 September 2026: the group's answer and each member's attendance
Accepting an event from another group used to put it on every member's schedule. It now records two separate facts:
- The group's answer (accepted or declined, with headcounts and a note), which is what the host sees. This is unchanged.
- Each member's attendance — Going, Not going, or no answer — which stays inside the responding group. The adult who accepts chooses who is going (themselves by default; children can be included). Everyone else starts with no answer.
Who can change attendance. A member can change their own. A group admin can change anyone's. A guardian can change their own under-18 child's. All three go through one server action; the database refuses any other change to attendance on a copied event, so a broad edit to the event row cannot be used to change somebody else's answer.
Where it shows. An accepted event appears on the Schedule only for members marked as going. Members who said no, or have not answered, still see the event, their answer and everyone else's in the group's Other Groups Events tool, which also lists declined events (which have no schedule entry at all) and pending proposals. That tool is shown to adult members only, as every other cross-group surface is.
What this does not change. The host still learns only the responding adult's name, the headcounts and the note — never which members are going. Attendance is a coordination fact within one group, in the same way as an ordinary event's RSVP; nothing counts or ranks it.
The tool and the assistant use the same server actions. The assistant can list these events, redeem an event code, add an event to another of the member's groups, answer a proposal, and change attendance within the same permissions. It does not repeat an event code in its replies.
1A. Step A as built (2026-09-08)
Everything in §§3–6 and §10 that concerns Step A is now a description of the code, not a plan. Where the build departed from the draft, the departure and its reason:
| Draft said | Built | Why |
|---|---|---|
| An RSVP per responding person | One answer per responding GROUP — unique (invitation_id, responder_group_id); a second adult in the same group replaces the first answer; one mirror per group; max_groups counts groups that accepted |
Two parents redeeming the same code are the same family answering. Two rows would have been two headcounts for one family and two copies of the party on one calendar |
| The host "sees" the responder's display name | responder_name is frozen onto the RSVP row at write time, and the host reads through list_event_responses, a SECURITY DEFINER RPC returning name, status, headcounts, note and when — a closed list. event_rsvps has a SELECT policy for the responding group only; the host has no row-level read at all |
The host cannot read the responder's users row (different group — correct), so the one fact they are meant to learn is copied. The responder's user id and group id never reach the host, structurally |
| The redeem RPC refuses an under-18 | Preview, respond and create all refuse an under-18, each with code: 'under_18', before anything is read and without charging the limiter |
A child should not see another family's event details from a code either. They see the party on their own calendar once a guardian accepts — as an ordinary row, proved in our database tests with a child's own session |
cross_group_events consent recorded "to the consent ledger" |
A USER consent (CONSENT_SUBJECT.cross_group_events = 'user', changed from 'group'), recorded from the accept screen when the person sends their first answer, after a sheet with the notice and two equal buttons |
§10 already said the consent is always an adult's. A group consent is admin-writable by policy, which would have stopped every non-admin adult from answering |
| Codes "stored hashed" | sha256 of the normalised code, with an honesty note in the migration: 32^6 ≈ 1.07 billion is brute-forceable offline in minutes by anyone holding the table. The hash defends against a glance and against a code in a log or a backup diff; the per-account limiter is what makes guessing through the API expensive | Saying "hashed" without saying this would be the overclaim this register exists to catch |
| "Codes are never logged" — a convention | A source scan, run as a test, over every file that holds a code and over the migration's raise statements |
The cheap upgrade §9 asked for |
| The host removes a response | Removal CANCELS the mirror (cancelled_at), in the same words as a host deleting the event; the RSVP row goes |
An event vanishing from a family's calendar because the host pressed a button is the failure cancelled_at exists to prevent, whichever button it was |
| — | The two link columns (linked_invitation_id, cancelled_at) are refused to any client write by a trigger; only the RPCs and triggers, under a transaction-local arm, may set them |
Without it a member of the responding group could point one of their own rows at any invitation id and receive the host's edits, or un-cancel a cancelled mirror |
| — | A mirror keeps the host's zone when the responding group relocates: the group-timezone re-anchor trigger skips rows with linked_invitation_id |
The party happens at the host's 2pm wherever the guests live; re-anchoring it would move it |
| — | A group accepting and every member attending are different facts; recording them as one put the event on the schedule of people who were never going | |
| — | is_standing_group(group_id) — true for every group today; event groups replace its body with groups.kind = 'group' |
So adding event groups is a one-function change, not a search for every place that should have asked |
| — | Erasure: event_rsvps.responder_user_id cascades. An erased account takes its name and answer with it; the mirror survives as the responding group's own calendar row |
The name on the row is personal data of the person erased; the calendar row is the group's record |
What each side learns, as built, is exactly the §5 table, and a test we run against the database asserts the key list of both closed returns rather than trusting the prose.
2. The design rule that comes before the schema
A child's interaction graph never leaves their guardian's groups. Anything cross-group that touches a child is addressed to an adult in the child's group and answered by that adult. Adults may interact across groups directly.
This is how parties already work for children in the physical world: the invitation goes to the parent, the parent answers. Building it that way costs nothing in realism and keeps every under-18's experience provably group-only.
The boundary is 18, not 16. Under-16 is the ban's line and 18 is the Code's definition of a child; for a brand-new feature that creates a cross-group surface for the first time, the Code's line is the one to take. A seventeen-year-old RSVPs through a parent. That is more restrictive than the in-group variant, which stays at 16, and the asymmetry is deliberate rather than an oversight.
Three lines not to cross, whatever this grows into. Each of them, crossed, moves the service toward the definition in the ban assessment in a way a regulator weighs by actual use:
- No directory or discovery of people or groups. Ever.
- No cross-group messaging for children.
- No child-to-child link of any kind.
3. Data involved
| Kind | Whose | Children's? | Sensitive? | Free text? |
|---|---|---|---|---|
event_invitations.code_hash — the invitation code, stored hashed (sha256; brute-forceable offline from a dump — §1A) |
the host group's | no | no | no |
event_rsvps.responder_name — the responding adult's display name, frozen at write time |
the responding adult's | no | no | no |
Event fields crossing the boundary: title, start_local_time + timezone, duration, place |
the host group's | often yes — "Ada's 8th birthday" names a child | no | yes — a title is free text |
event_rsvps: responder user, responder group, status, adult headcount, child headcount, note |
the responding adult's | headcounts are about children, unnamed unless the note names them | possibly — an allergy typed into the note is health information | yes — the note |
The mirrored group_schedule_events row in the responder's group |
the responder group's copy of the host's event | as above | no | yes |
groups.kind, parent_group_id, ends_at, archived_at (no delete_after — §1B) |
the event group's | no | no | no |
group_members.event_home_group_id — which of a joiner's own groups the party should land on |
the joiner's, about their own groups | no | no | no |
| Event-group chat and bring-list tasks | adults from several groups | no — children are never members | no | yes |
What is deliberately not collected, and is the more interesting list:
- No email lookup, no user search, no "is this person on the app". There is no RPC that could answer it. An enumeration oracle over personal information is precisely what a convenience feature here would become.
- No guest list crossing back. A responder never learns who else was invited or who else accepted.
- No roster ever crosses. Neither side sees the other group's member list.
- No child is ever enumerable across a boundary. A headcount of children is a number; a name only appears if an adult types one into a free-text note.
- Codes are never logged. Not in application logs, not in edge-function logs. A code in a log is a live credential sitting somewhere an account erasure cannot reach — our own rule that an identifier written to a log outlives any deletion we can perform on it.
4. Flows and locations
| Data | Device | Supabase (Sydney) | Crosses a group boundary? | Model provider |
|---|---|---|---|---|
| The plain invitation code | exists only in the share sheet on the host's device | only as a hash | yes, out of band (the host sends it however they like) | no |
| Preview (title, time, place, host's first name) | shown to the redeemer | via preview_event_invitation, rate-limited per account |
yes — before any relationship exists | no |
| The RSVP (name, headcounts, note) | typed by the responding adult | stored | yes, to the host | no |
| The mirrored event | rendered in the responder's group | ordinary group_schedule_events row |
yes, host → responder | yes — it is an ordinary row, so the responder group's own assistant can see it, exactly as it sees any event |
| Event-group chat | stored | it is the boundary | no. Never read by any model — it is other groups' adults talking |
The mirror carries the host's start_local_time and timezone, so the existing dual-storage
trigger derives the same instant and each group renders it in its own zone. That is a
correctness decision, and it also means no new time-handling code exists to get wrong.
Two model-exposure decisions worth stating explicitly:
- The mirrored event reaches the responder group's assistant, because it is an ordinary row in that group's calendar and the assistant reads the calendar. That is the intended behaviour — a family should be able to ask "what's on Saturday" — but it means another group's event title, which may name another family's child, is in the context of a model call. A planned change routing group data to Australian inference is the answer; until then it follows whatever the assistant's region is. The transparency copy must not claim otherwise.
- Event-group chat text never reaches any model. Our assistant server re-reads
groups.kindon every request carrying a group id — the voice-grant pattern — and refuses event groups outright; the Assistant and Tools tabs are hidden for the kind. In v1 a participating group's own assistant sees the mirrored event only; bring-list tasks assigned to that member are a v2 candidate; chat never.
5. What each side learns, and what it does not
| The host learns | The responder learns | |
|---|---|---|
| Identity | The responding adult's display name | The host's first name — and, once their group has answered, the host group's name (below) |
| Content | Headcounts (adults, children) and the responder's note | Title, time, place |
| Never | The responder's group name, roster, or any other member; any child's name unless typed into the note; whether the responder is on the app before the code is redeemed | Who else was invited; who else accepted; the host's group roster; the host's notes on the event (host-controlled, not mirrored) |
The preview is the sharpest edge in the design, because it is the one call that returns information about strangers to someone with no relationship to them. Four things bound it:
- It returns title, time, place and the host's first name, and nothing else — a closed list, not a filtered row.
- It is rate-limited per account, in the shape
request_join_groupalready uses (5 failed guesses per 15 minutes; a sixth is refused and not charged). - It refuses an under-18 before reading anything — built, not only the redeem (§1A).
- The RPC returns
{success, error}rather than raising, because a rate limiter that raises an error instead of returning a result writes no row and counts nothing — it would protect nothing. Which failures are charged is a decision, not a detail: a code that does not exist is charged; expired, revoked and exhausted are not, because the caller was holding a real code. - Codes are hashed at rest, so the table is not a list of live invitations to anyone who reaches it.
A forwarded code is the accepted failure mode. A code passed on reaches someone the host did
not choose. Bounds: max_groups caps redemptions, expires_at defaults to the event's end plus
a day, the host can revoke the code and remove a response, and the preview reveals only what a
paper invitation would. For event groups, joining keeps the existing pending → approve ceremony,
because approval grants chat membership and that is a higher bar than seeing a date.
The host group's name reaches the responder only after their group answers. The preview, before any relationship exists, still shows the first name alone. Once a group has answered — accepted or declined — its adult members see the host group's name beside the event in Other Groups Events, the same way an event from a connected group is shown, and that group's assistant reads it when it lists those events. Children never see it: that list refuses an under-18 before reading anything. We decided this on 10 October 2026: a family with several invitations needs to tell "the Smiths" from "Jo", and a paper invitation is usually signed by the family. The app had been showing the name since 25 September, and this assessment said otherwise until 10 October. The host is told on the screen where they share the code. The residual is in §13.
6. Under-18s: what the rule looks like in the database
respond_to_event_invitationrefuses an under-18 caller. Not a hidden button — an RPC refusal, checked server-side, proven in our database tests with an under-18 account.- An under-18 cannot request to join an event group.
- A child sees the party on their own group's calendar, as an ordinary event, once a guardian has accepted it. Nothing about the experience says "cross-group"; it is simply on the calendar.
- No child appears in an event group's member list, so an event group's chat is adults from several groups and its photo wall (§8, planned) holds photographs posted by adults.
7. Event groups: caps and lifecycle, stated on screen
The caps are a privacy design as much as an abuse control: a temporary group that quietly became permanent is a cross-group social space, which is the thing this app is not.
| Default | Built | |
|---|---|---|
| Active event groups per founding group | 3 | create_event_group, counted over archived_at is null — an archive is a record, not a live space, so a household that has thrown four parties is not locked out by the first three |
| Members | 50 | the invitation's max_groups; the group's own invite cap is the ordinary one |
| Read-only from | the event + 7 days | sweep_event_groups, nightly at 03:20 UTC |
| Withdrawn before this was built, 8 September 2026 | NEVER deleted on a timer. Kept as an archive until an admin of the host group deletes it — §1B, and the ban self-assessment §5.4 |
Stated in plain words on the create screen and the join screen, from one function
(eventGroupLifecycleCopy), and the group's card in My Groups shows both dates. Somebody
joining a temporary group is entitled to know before they type anything what it holds, when it
stops taking writes, and what happens to it afterwards. The third sentence is the one that
would have been wrong, and a test we run scans every string the feature can render for a
promise of automatic deletion.
create_event_groupenforces the cap and returns{success: false, code: 'cap'}rather than raising, along with every other refusal (a child, a non-member, an event group trying to host one).- What an event group structurally cannot have: one
as restrictivepolicy per tool table,with check (is_standing_group(group_id)), on 29 tables —group_babiesand every baby table, meal-plan instances and collections, pantry, game settings and the game ledger, duty handoffs, voice grants, photo albums and photos, andchat_sessions. Additive, one predicate, ANDed with the existing permissive policies, and proven by an adult member of an event group trying each insert and by the same insert succeeding in their own standing group. A hidden tab is not enforcement.- AND A RESTRICTIVE POLICY IS NOT ENOUGH WHERE A SECURITY DEFINER FUNCTION WRITES. Nothing
in this schema has
force row level security, so a policy is skipped inside a function owned by the table owner. For the baby domain that is not academic —log_baby_event,log_feedandlog_past_feedtake a group id straight off the request and the row is a child's health record — so BEFORE INSERT triggers close it there. Elsewhere the gap is stated rather than closed (§1B), which is the honest position and the one this register exists to make legible.
- AND A RESTRICTIVE POLICY IS NOT ENOUGH WHERE A SECURITY DEFINER FUNCTION WRITES. Nothing
in this schema has
- Archival is enforced by a restrictive policy on writes, not by the client. A nightly
sweep_event_groupsARCHIVES, is idempotent, and never deletes. Reads are untouched. - No group-specific assistant. §4.
8. The photo wall in an event group — built 9 September 2026
Re-assessed 9 September 2026, as built. The three rules below were written when this was a deferred design; each is now either held by the database or explicitly not, and this preamble says which. The detail of the sealing itself is in the photo-wall assessment, revised the same day.
| The rule as designed | As built | Structural or conventional |
|---|---|---|
| Ships only once photo encryption exists, so no event group holds an unencrypted photograph | Event groups' two restrictive INSERT policies on group_photos / group_photo_albums are replaced, not dropped: an event group admits a row only where encrypted = true, and the bucket function admits an event group's objects only under the sealed/ prefix |
Structural. Proved both ways in our test suite for photo sealing — an unsealed row is refused, a sealed one admitted |
A copy is a new object, re-encrypted on the copying device, carrying source_photo_id and a provenance line |
Built. copy_group_photo is SECURITY DEFINER solely so the source_photo_id is a photo the caller can genuinely see; the bytes are decrypted, re-sealed under the target group's key and uploaded by the device. The provenance label is itself sealed under the copy's own data key |
Structural for the link and for "the server never sees plaintext"; the label's wording is the client's |
| The poster's delete offers "also remove the copies", on by default | Built as a durable tombstone: request_photo_copy_removal hides every copy in the same statement (a restrictive SELECT policy on copy_removed_at) and files a group_photo_copy_removals row; sweep-photo-copies removes the objects and then the rows |
Structural for the hiding; the byte removal depends on a scheduled sweep being wired up |
| Account erasure removes copies of that person's uploads the same way | Built into delete-account, in the same pass as its own storage sweep |
Structural |
| Copies survive the event group's own lifecycle, and the copy button says so | Built. An event group is archived, not deleted, seven days after the event (§5) and only a host-group admin deletes it; a copy is an ordinary photo of the copier's own group from the moment it lands | Structural, and the copy sheet says it in words |
| A destruction request about a child's photograph is honoured by the poster or a group admin, not by us | Built as far as code can build it: reports reach that group's admins who did not post the photo (Manage Group → Reported photos, a marker on the wall and a push), and no service-role reader of reports exists anywhere. The written procedure for this is still owed — this assessment does not close it | Conventional, and the honest word is conventional |
What the build added that the design did not say. Reporting had to change with it. A report
used to be addressed to us; with the photograph sealed we cannot look at it, so a report now
goes to that group's admins, and the reporter is still never named (the admin-facing function
does not select reported_by). That is a real reduction in what we can do about a bad photograph
in an event group, and it is assessed in the photo-wall assessment §8 rather
than hidden here.
The original text follows, unchanged.
We decided event groups get a photo wall, and guests may copy a photo to their own group's wall — provided it holds up against deletion rights. It does, on three rules, and it ships only once photo encryption exists, never before, so that no event group ever holds an unencrypted photograph of another family's child.
- A copy is a new object, re-encrypted on the copying device under that group's key,
carrying
source_photo_idand a provenance line — "from Maya's party, posted by Sam". The server never sees either plaintext. - The poster's delete offers "also remove the copies", on by default. The provenance link makes a cascade possible without the server reading anything. Account erasure removes copies of that person's uploads the same way. Copies survive the event group's own scheduled deletion, which is the point of copying, and the copy button says so.
- A destruction request about a child's photograph is honoured by the poster or a group admin, not by us, because with the photo encrypted we cannot identify it. The written procedure for this must say so in plain words rather than promising an operator search that end-to-end encryption makes impossible. An honest limitation stated is better than a capability implied.
The residual, stated: a copy lands on a group wall where that group's own children may see it. That is the same exposure as any photograph their own parent posts, and it is the reason children are never members of the event group itself.
9. Risks and mitigations
| Risk | Who is harmed | Mitigation | Structural or conventional | Residual |
|---|---|---|---|---|
| The preview RPC becomes an enumeration oracle | anyone with an account | Closed return list, per-account rate limit, hashed codes, no email or user lookup anywhere in the app | Structural | A determined attacker with many valid codes learns those events. Bounded and accepted |
| A forwarded code fills an event with strangers | the host, their guests | max_groups, expiry, revocation, host removal; approve ceremony for event groups |
Structural | Accepted — §5 |
| A child interacts across a group boundary | the child | RPC refusals on preview, redeem and create (built) and on join (planned), checked server-side, proven with an under-18 account | Structural (preview, redeem and create built and proved; join planned) | — |
| Another family's child's name reaches a model in a mirrored event title | that child | A planned change routes group data to Australian inference; free text is not filtered | Conventional until then | Accepted and must be stated in copy. A title is free text and the app cannot know it names a child |
| A note carries health information — "Ada is anaphylactic to peanuts" | that child | Nothing structural. It is exactly the information a host needs | — | Accepted. The note field's copy should not invite more than is needed |
| An event group becomes a permanent cross-group social space | the app's ban position; the children in those groups | Caps, lifecycle, restrictive policies, no assistant, no children | Structural | Accepted |
| Codes in logs | anyone holding a code | "Codes are never logged" — a source scan over every file that holds a code and the migration's raise statements (a test we run) |
Structural for the app's own code (a test); still conventional for any future edge function or landing page | Accepted. The scan's file list must grow with the feature |
| A responding group forges a mirror or un-cancels one | the host, the guests | The two link columns are refused to any client write by a trigger; only the RPCs and triggers, under a transaction-local arm, may set them | Structural | — |
| The hash is mistaken for protection of a leaked table | anyone holding a code | The migration says in words that 32^6 is brute-forceable offline; the limiter is the real defence against guessing through the API | Conventional (a stated limitation) | Accepted. A leaked table is a leaked list of live codes until they expire |
| A copied photo outlives the subject's family's control | a child in the photo | Provenance link, cascade delete on by default, admin honour of a destruction request | Provenance is structural; the honouring is conventional | Accepted, and disclosed — §8 |
| A host deletes the event and it silently vanishes from six families' calendars | the guests | A host delete sets cancelled_at on the mirrors and the card says so; a host removing one group's response does the same |
Structural (built, proved) | — |
10. Legal basis and consents
- The first answer writes a
cross_group_eventsconsent row to the consent ledger, as a user consent, from the accept screen: the notice is on the screen in plain words every time, and the first time (or after the twelve months lapse) sending the answer is preceded by a sheet with the same words and two equal buttons. The row is the record of that act, matching the wording shown on the consent screen. - The consent is always an adult's, because an under-18 cannot redeem. There is no guardian-on-behalf-of-a-child consent in this feature, which is a consequence of the §2 rule rather than a separate decision.
- The notice is specific, not bundled (s 14(3)(b)): it names the host's first name, the event, and the three things the host will learn — your name, your headcounts, your note — and the two things easy to leave out: that everyone in the chosen group sees the event on the calendar, and that the group's assistant can read it like any other event. It claims nothing about encryption or anonymity; a test we run asserts the absence.
- Withdrawal is
withdraw_rsvp, which removes the response and the mirrored event. - Twelve-month expiry (ss 15–16) applies as it does to every purpose; in practice an event is over long before that, so the live case is a household that has stopped attending cross-group events being asked again.
11. The ban self-assessment, re-run for this feature
The ban self-assessment concluded the app is not an
age-restricted social media platform, on limb 63C(1)(a)(i) alone. This feature is the one most
capable of undermining that conclusion, so the test is re-run rather than assumed.
| Element | Before | With this feature |
|---|---|---|
(a)(i) sole or significant purpose is online social interaction |
Arguably not — organising | Weakened, and the honest word is weakened. A party is a social occasion, and a feature that connects families is closer to social interaction than a chore list is. The counter is that what crosses is a calendar invitation — a date, a place, an answer — and that an event group is a coordination space with a bring list and a deletion date, not a place to hang around. Still arguable; less comfortably. |
(a)(ii) link to or interact with some or all other end-users |
Yes | Yes. Unchanged in substance: adults can now interact across a boundary, but only with someone who handed them a code. There is still no way to find anyone. |
(a)(iii) post material |
Yes | Yes |
(a)(iv) Rules s 4A condition |
Yes, via feedback features | Yes. Unchanged. |
| Rules s 5 exclusion | None | None |
What preserves the argument, and must be preserved with it:
- No directory, no discovery, no search. A code someone gave you is the only way in. This is the single most important property in the feature and it should be treated as load-bearing.
- No cross-group chat outside an event group, and an event group is capped, goes read-only a
week after the event, and holds nothing but a chat, a bring list and the one event — every tool
refused by the database rather than hidden. It is not deleted on a timer, and that is a
deliberate trade recorded in §1B: a disappearing group would be a
4A(6)time-limited feature, which costs the app more than a permanent archive of a party's bring list does. - No under-18 crosses a boundary at all, so every child's experience remains provably group-only. This is a privacy-Code and harm-reduction property, not a ban argument: eSafety's guidance (ban self-assessment §1.3, 2026-09-08) gives age-based restriction of social features no weight in the purpose test, so what matters for the ban is what the feature does to the service as used by everyone — which is why the next two bullets are the load-bearing ones.
- No photo wall in event groups until photo encryption exists, and none at all in Step A.
What would break it: any people search; any "groups near you" or suggested-events surface; any persistent cross-group space; any child able to redeem, join or message across a boundary; any ranked or endless feed of events. Each of those is a normal-looking product idea and each one moves this feature from "a calendar invitation" to "a network".
12. Best interests of the child (exposure draft s 10)
What children get. Their family's social life is legible on their own calendar. A child sees "Maya's party, Saturday 2pm" the same way they see soccer training — which is the correct amount of involvement for a child in an arrangement their parent made.
What they are spared. They cannot be contacted by an adult from another family, they cannot be added to a group chat with children they have not met, they cannot be enumerated by anyone outside their own group, and their name reaches the host only if their own parent types it.
The design cost, and who bore it. A seventeen-year-old cannot RSVP to their own friend's party in the app, which is patronising and they will notice. The Code's line was taken over convenience for a brand-new cross-group surface, on the reasoning that the first version of a boundary-crossing feature should be the restrictive one — loosening later is a decision someone makes deliberately, and tightening later takes something away that people have started using. This is the item most likely to be revisited, and revisiting it is legitimate.
Where a child's interest and the host's diverge: the host wants to know who is coming and whether anyone has an allergy; the child's interest is in not having their name and health information typed into another family's app. The parent decides, in a free-text field, having been shown what the host will see. That is the right person to decide, and the notice at first redemption is what makes it an informed decision rather than a default.
13. Residual risk we accept
- This feature weakens the ban argument's strongest limb. §11. Accepted, with the §11 preservation list as the standing constraint and the assessment re-run on any change to it.
- A free-text note can carry a child's name and health information across a group boundary, and nothing filters it. Accepted — it is the information a host actually needs — with the notice at first redemption as the mitigation.
- A mirrored event title reaches the responder group's assistant. Accepted; addressed rather than removed by our planned Australian-inference rule, and it must be stated in the copy.
- A forwarded code reaches someone the host did not choose. Accepted; bounded by caps, expiry, revocation and the approve ceremony.
- Anyone whose group answers a code learns the host group's name, including someone the code was forwarded to. A group's name often carries a surname, so with the host's first name it can identify a family. Declining is enough, and declines are not capped the way acceptances are. Accepted, because the name is what makes an invitation recognisable; bounded by needing a code, its expiry and the host stopping the codes working, and the host is told when sharing it (§5).
- We cannot honour a destruction request about an encrypted copied photograph ourselves. Accepted and disclosed; the poster or a group admin does it, and our written procedure for it says so plainly.
- "Codes are never logged" is a test for the app's own code, and a convention for anything outside it (an edge function, a future public landing page). Accepted.
- A leaked
event_invitationstable is a list of live codes until they expire, because the hash is over a 30-bit space. Accepted and stated in the migration. - Added 14 October 2026: someone who joins an event group late sees its history, including photos another family posted of their children. Accepted, as the amendment above explains; each joiner is told before they join.
- An archived event group is kept indefinitely, including its chat, unless an admin of the
host group deletes it. Accepted, and it is the deliberate consequence of §1B: the alternative
is a retention timer on member-posted content, which is the
4A(6)limb. Erasure of an individual account still removes that person's rows as it does everywhere else. - Outside the baby domain, a restrictive tool-table policy is skipped by any SECURITY DEFINER
function that writes that table. Accepted and stated (§1B): the RPC would have to be handed
an event group's id by hand to write a row nothing renders, and closing it properly means
force row level securityacross thirty tables, which is a wider change than the risk earns. The baby domain is closed by triggers rather than left in this bucket.
14. Review trigger
- Before RSVP invitations started — this document was the gate.
Before event groups started, re-read §7 against what RSVP invitations actually built.Done, 2026-09-08 — §1B is the record, andis_standing_groupis the one function event groups had to change.- Before the event-group photo wall ships — §8 is re-assessed then, not now.
- Any change to the §2 rule, the §11 preservation list, or the age boundary in §6.
- If bring-list tasks or anything else from an event group becomes visible to a participating group's assistant (the v2 candidate in §4).
- Any change to whether a late joiner sees an event group's history.
- When the universal invite link replaces code-only transport — a public landing page is a new surface and must not log invite codes.
15. Sources
Primary: Online Safety Act 2021 Part 4A and the Rules — see
2026-09-ban-self-assessment.md §14, where they are quoted
and the verification is recorded. Children's Code exposure draft ss 10, 14–17, 32, 38 — via
docs/privacy_posture.md §4.2, not re-read for this document.
In repo: our own rules that a rate limiter which raises an error instead of returning a
result records nothing, that an identifier written to a log is data an account erasure cannot
reach, that row-level security must be switched on before a policy is written on top of it, and
the dual-storage timezone rules the mirror relies on; the rate limiter added to
request_join_group, whose shape the preview RPC copies.
Built (8 September 2026): Step A — §1A, §§3–6 and §10 as they concern RSVP invitations; the
database migration that added event invitations, and __tests__/event_invitations_rls.test.ts.
Built (8 September 2026): Step B — §1B and §7; the database migration that added event
groups, __tests__/event_groups_rls.test.ts, lib/__tests__/eventGroups.test.ts,
__tests__/app/groups-event-section.test.tsx, agent-server/checks/eventGroups.check.ts.
Built (9 September 2026): §8 — the event-group photo wall, with the photo-encryption work.