← 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 soas 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 GROUPunique (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:

  1. No directory or discovery of people or groups. Ever.
  2. No cross-group messaging for children.
  3. 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:

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:

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:

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

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
Deleted 30 days after that 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.

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.

  1. A copy is a new object, re-encrypted on the copying device under that group's key, carrying source_photo_id and a provenance line — "from Maya's party, posted by Sam". The server never sees either plaintext.
  2. 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.
  3. 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

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:

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

14. Review trigger

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.mdA 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.