← All privacy impact assessments
PIA: cross-group events — RSVP invitations and event groups
Status: Step A (WS6, RSVP invitations) is BUILT — 2026-09-08, migration
20260908022755_event_invitations; §1A records what was built and where it differs from the
draft. Step B (WS7, event groups) is BUILT — 2026-09-08, migration
20260908035756_ws7_event_groups; §1B records it, and §7 is now a description of the code.
Written: 2026-09-07 · Revised: 2026-09-08 (WS6 and WS7 built) · Issue: #331 ·
Workstreams: WS6 (RSVP invitations), WS7 (event groups), WS12 (the event-group photo wall,
which ships with photo encryption and not before)
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.
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.
Step A — RSVP invitations (WS6). 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 (WS7). 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.
1B. Step B as built (2026-09-08)
THE OWNER CHANGED THE LIFECYCLE BEFORE THIS WAS BUILT, AND THE REASON IS 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; the owner's later "groups, not households" decision (AGENTS.md) 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 owner too |
This is the structural-vs-conventional distinction the register 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 |
AGENTS.md → The Avatars Bucket Authorises By PATH: the metadata row and the object are two write paths |
| — | The four new groups columns are refused to every client write by a trigger under a transaction-local arm, the shape WS6 used for the 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 WS6 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 … agent-server re-reads groups.kind on every request" |
Built, at both doors and in three places: buildAgentContext calls is_standing_group beside the membership check and fails closed (/api/agent and voice ask); voice's per-turn groups read carries kind and refuses BEFORE dispatch, which is what covers chat.last; and the voice PAIRING endpoint refuses too. agent-server/checks/eventGroups.check.ts is the source scan |
chat.last is the one that would otherwise read another family's party chat aloud 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 |
AGENTS.md → the grant IS the group |
What is NOT built, and is not claimed: the event-group photo wall (§8) — still WS12, and
group_photo_albums / group_photos are on the restrictive list precisely so it cannot arrive by
accident.
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 event-group photo wall, which is designed in §8 but ships only with the
photos encryption workstream (WS12) and is assessed again then; the child-account design
itself (child-accounts.md); the universal invite link (#311), which is
a transport decision, not this feature.
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 the db suite with the child's own session |
cross_group_events consent recorded "to the WS2 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 (20260907230010), 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 (lib/__tests__/eventInvitations.test.ts) 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 |
| — | The mirror is assigned to every active member of the responding group at the moment of acceptance (the shared-dinner convention) | The calendar's member filter defaults to "me"; an unassigned row is invisible to everybody, which is the opposite of what a family accepting a party wants |
| — | is_standing_group(group_id) — true for every group today; WS7 replaces its body with groups.kind = 'group' |
So WS7 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 __tests__/event_invitations_rls.test.ts
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 erasure cannot reach (AGENTS.md → Identifiers In LOGS Are Data An Erasure Cannot Reach).
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. Under WS20 (planned) group data is processed in Australia, which 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.
agent-serverre-readsgroups.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 only |
| 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 writes no row and counts nothing (AGENTS.md → A Rate Limiter That RAISES Records 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.
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 the db suite 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, WS12 only) 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 by the owner, 2026-09-08, before this was built | 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 lib/__tests__/eventGroups.test.ts 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 (WS12, 2026-09-09)
Re-assessed 2026-09-09, 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
photo-wall.md, revised the same day.
| The rule as designed | As built | Structural or conventional |
|---|---|---|
| Ships only with WS12, so no event group holds an unencrypted photograph | WS7's 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 __tests__/photo_sealing_rls.test.ts — 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 through photo_reports_for_group, and no service-role reader exists anywhere. The #228 procedure text 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 photo-wall.md §8 rather than hidden here.
The original text follows, unchanged.
Owner decision: 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 with WS12 (photo encryption), 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 #228 procedure must say this 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, WS6) and on join (WS7, planned), checked server-side, proven with an under-18 account | Structural (WS6 built and proved; WS7 planned) | — |
| Another family's child's name reaches a model in a mirrored event title | that child | WS20 routes group data to Australian inference (planned); free text is not filtered | Conventional until WS20 | 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 (lib/__tests__/eventInvitations.test.ts) |
Structural for the app's own code (a test); still conventional for any future edge function or landing page (#311) | 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 ledger (#219, WS2 — built), 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 (lib/eventInvitationCopy.ts→crossGroupConsentNotice). - 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;
lib/__tests__/eventInvitations.test.tsasserts 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 WS12, 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 accepted by the owner
- 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 WS20's 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.
- We cannot honour a destruction request about an encrypted copied photograph ourselves. Accepted and disclosed; the poster or a group admin does it, and #228's procedure 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, the #311 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. - 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 WS6 starts — this document is the gate.
Before WS7 starts, re-read §7 against what WS6 actually built.Done, 2026-09-08 — §1B is the record, andis_standing_groupis the one function WS7 had to change.- Before the event-group photo wall ships with WS12 — §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).
- When #311's universal 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: AGENTS.md → A Rate Limiter That RAISES Records Nothing, Identifiers In LOGS
Are Data An Erasure Cannot Reach, Check RLS Is ENABLED Before Writing Policies, Calendar
Events & Multi-User Support (the dual-storage timezone rules the mirror relies on);
supabase/migrations/20260823235140 (request_join_group's rate limiter, the shape the preview
RPC copies); issues #331, #219, #228, #311.
Built (WS6, 2026-09-08): Step A — §1A, §§3–6 and §10 as they concern RSVP invitations;
supabase/migrations/20260908022755_event_invitations.sql, __tests__/event_invitations_rls.test.ts.
Built (WS7, 2026-09-08): Step B — §1B and §7; supabase/migrations/20260908035756_ws7_event_groups.sql,
__tests__/event_groups_rls.test.ts, lib/__tests__/eventGroups.test.ts,
__tests__/app/groups-event-section.test.tsx, agent-server/checks/eventGroups.check.ts.
Planned, not built: §8 — the event-group photo wall, which ships with WS12 and not before.