← All privacy impact assessments

PIA: Tier 2 through KMS — a second wrap of the group key that only the server can open, inside a member's request

Status: Built. This assessment was completed before the underlying database change and code, as our practice requires, and the design it describes has been live since 9 September 2026. Two further changes below, both dated 30 September 2026, have been built and merged but not released. Written: 9 September 2026 · Last revised: 15 October 2026

Amendment, 30 September 2026. Separating the key our server may use from the key it must never hold was built the same day it was assessed and describes a change where, from a new key version, our server receives only a narrower key derived for the content this assessment covers, never the whole group key, and existing content is moved onto the new keys. That work has not been released; until it is released and a group has completed the move, this assessment describes the app as it runs today: the copy our server holds is the whole group key. Amendment, 30 September 2026 — each member sees only their own audit rows. Built, not yet released. The audit table used to be readable by the group's admins as well as by the member each row names. A group-wide figure is a record of how often other people asked the assistant for something, and in a group of two adults a total minus your own is the other person's. Once released, the table will be readable only by the member a row names, and each member will be able to read their own rows in the app, on a screen called Key access record. Group-wide figures wait for their own assessment of whether showing them would tell a member how others used the service, and of what a small group could infer from them. The limit on how many unlocks a member may cause in an hour counts only that member's own rows, so it is unchanged. The screen will say what it does not show: other members' requests, what was read inside a request, whether the key was actually released (a row is written before the request), and anything from before the record was started.

Amendment, 30 September 2026 — each unlock attempt records what happened next, and a nightly check compares our record with Google's. Built, not yet released. Each audit row will also carry an attempt id and an outcome: the key was released, our server stopped before asking, or the request for the key failed. Our server will write the outcome through the same member's session as the row itself, once, and nothing else about the row will be able to change; the member will see it beside each line of their key access record. A nightly job has been written, and is off until we turn it on, to read Google's own record of every release of the key-wrapping key and, from our database, only the number of attempts that started in each minute by outcome — no member, group, purpose or id — and to alert us to any release that no attempt accounts for. This is detection, not proof: one release can open many items inside a request, a copied key needs no further release, and because the outcome and the row are written with a member's own session, a member could add rows of their own, which can hide an extra release in the count but never cause a false alert.

Amendment, 3 October 2026 — the Key access record screen has been removed. The two amendments above describe a screen in Settings on which each member could read their own audit rows and each row's outcome. We removed it, because Settings should not ask members to read explanations of this kind. Nothing about the record itself changed: our server still writes a row before every unlock, each row is still readable only by the member it names, and admins still cannot read anybody else's. The app no longer shows these rows anywhere, so any group-wide figure, or a screen that shows these rows again, still needs the assessment described above first.

Amendment, 6 October 2026 — our server's copy is offered again when it is missing. Built, not yet released. From 8 September to 6 October 2026 no group's copy reached our server: the offer, made once when a group's key was created or changed, was refused by a setting on our cloud project, and nothing tried again. So the assistant could read no group's event notes, and could not sort an encrypted email. The setting is fixed. Now, whenever the app comes to the front on an owner's or admin's phone in a Standard group and finds that our server holds no copy for the group's current key, it offers one again: the same copy, with the same checks, that the group should have had all along. It is never offered for an earlier key, which may be one the group used while Device-only, and never in a Device-only group. Every other member's phone only checks whether a copy exists. This does not widen what our server can read. It restores what this assessment and the privacy policy already describe for a Standard group.

Amendment, 15 October 2026 — our server will open encrypted content only for a phone that could open it itself. Assessed before the code; not yet built. A phone that doesn't hold a group's key shows that group's encrypted tasks, event notes, shopping lists and Home brief as locked, and explains that an owner or admin needs to share the key. Reviewing that screen, we found our assistant server doesn't ask the same question. It opens a group's encrypted content for any active member of the group, whatever phone the request comes from: the only checks are that the member belongs to the group and the hourly limit. So a member could ask the assistant about something their own phone was showing as locked, and be told what it said, and the Home brief could be asked for in a way that returned its words opened. Chat, photos and Device-only groups were never affected, because our server holds no key that opens them. This matters most for three people: someone who has taken over a member's account but holds none of their phones; anyone holding our database's service key, who can sign in as any member; and a phone whose key delivery a member stopped or revoked, since the account behind it could still ask the assistant. The change assessed in §3.1 makes every such request show that it comes from a phone holding the key for what it asks to open. Until it is built and switched on, the rest of this document describes the app as it runs today. See §3.1, §5, §6, §8 and §9.

Nothing here is legal advice. Children's Code citations are to the exposure draft; section numbers may move on registration (due 10 December 2026).

The one claim to be careful with. A calendar event's notes and location are now sealed on the device under the group key, exactly as a chat message is — and, unlike a chat message, the group key is additionally wrapped under a key in GCP KMS so that our assistant server can open those two columns for the assistant inside one member's request. So this document may never say "we cannot read" about these columns. What it may say is narrower and true: the words are ciphertext in Supabase; the key that opens them is not in Supabase; the server reads them only inside a request a member made, only in memory, only after writing down that it did; and a group that has not shared the wrap (Locked, once that option ships) cannot be read by the server at all.


1. Purpose and scope

Our target is "I couldn't see your group chat messages or photos, even if I wanted to", and an assistant that still works on the group's calendar. The encryption plan resolves that with two tiers: chat, photos and baby notes are Tier 1 (device keys, nothing on any server can open them); task notes, event descriptions and locations are Tier 2 — encrypted at rest, decrypted only for use.

Correction, 30 September 2026. That was the design's intent, and the mechanism below does not keep it. Tier 2 wraps the group's ONE key for the server, and that same key protects chat and photos, so the server's copy could open them too; what stops it is our code (the reader's shape and its check), not a missing key. Baby notes were never sealed at all. The privacy policy and the app's Privacy screen now say both plainly, and separating the keys is planned work.

This assessment covers the Tier 2 mechanism as revised on 9 September 2026 (device encrypts, server holds a second wrap) and its first two columns: group_schedule_events.notes and group_schedule_events.location. The column the plan calls description is notes in the schema; there is no description column.

Out of scope, deliberately:

2. Data involved

Kind Whose Children's? Sensitive? Free text?
group_schedule_events.notes — now sealed_notes under a per-row data key the member who wrote it, about the group's plans often — "Ada's paediatrician, bring the red book" is the ordinary case in a family calendar can be: an appointment's notes are health-adjacent by nature yes
group_schedule_events.location — now sealed_location the same often (a school, a clinic, a friend's house) a place a child will be, at a time — the thing this app most needs to keep from a stranger yes
wrapped_data_key, key_version, encrypted structure no no no
group_key_wraps row with wrap_kind = 'kms': the group key, encrypted under the KEK with groupId:keyVersion as additional authenticated data the group's no it is a key: inert without the KEK, and the KEK is in another company's project no
kms_unwraps audit row: group_id, user_id (who triggered the read), purpose, key_version, row_count, created_at; from 30 September 2026 also attempt_id (random), outcome (released / stopped / failed) and outcome_at the reading member's, the group's no no — a count and a purpose, never the words no
Per-minute attempt counts for the nightly check: a minute, an outcome, a number nobody's in particular — summed across every member and group no no no

Not collected, and could have been: nothing about which event was opened (the audit carries a row count, not ids — an id list would be a second index of the calendar); nothing the model said back; no copy of the opened text anywhere (not in a log, not in a cache, not in kms_unwraps); no group key on the server between requests.

Strict necessity (s 38(2)(b)): the notes and the location already exist — this mechanism does not collect anything new. The KMS wrap is necessary for the assistant to answer "what's on Thursday and where" in Standard; a group that judges it unnecessary will be able to withhold it (Locked). The audit row is necessary for one of our design invariants — a decrypt nobody can evidence is the thing it forbids.

3. Flows and locations

Data Device Supabase (Sydney) GCP (australia-southeast1) Model provider Region
Event notes / location, new row sealed on the device before the insert, under a per-row data key wrapped to the group key ciphertext + wrapped data key; plaintext columns hold the fixed placeholder Encrypted — inside an assistant turn only, opened (see below) Sydney at rest; provider global in flight
Event notes / location, existing row resealed on edit by any device holding the key; older rows are sealed by a resumable background job that calls seal_schedule_event as above; rows the job has not reached stay plaintext — — Sydney
Event created by the assistant (schedule_new_event, set_recurring_event), by voice, or by create_event_group — plaintext — the server holds no group key and never will; sealed when the history job next runs — — Sydney
Group key (32 bytes) minted on a device, wrapped per device — POST /api/keys/kms-wrap: an admin device sends it over TLS inside its own request; our assistant server calls KMS encrypt and inserts the kms wrap through the caller's RLS-bound client. In memory for that request only — australia-southeast1
KEK never never KMS, project group-organisation-app-kms, key ring groupapp, key group-key-wrap; auto-rotates every 90 days (any enabled version decrypts, so a rotation needs no app change) — australia-southeast1
Opened notes / location — — Our assistant server, inside one tool call of one request: mint a 300-second token as kms-unwrapper@…, KMS decrypt the wrap, unwrap the row's data key, open the text, wipe. Nothing cached across requests the opened words are part of the tool result the model sees, exactly as before this mechanism existed australia-southeast1 → provider
kms_unwraps audit row — written before the decrypt, through the caller's client (user_id defaults to auth.uid()); the decrypt is refused if the insert fails; the outcome is added once afterwards, through the same client — — Sydney
Nightly check — a database role that may call one function returns per-minute counts by outcome — nothing that names a member, a group or a purpose reads Google's record of each release of the key-wrapping key (time, succeeded or not, which service account) from a log bucket in the key project, with an identity that may read that one bucket and nothing else — Sydney and australia-southeast1 at rest; the comparison runs on a GitHub Actions runner (GitHub's region), which holds the counts and the times for the length of the run and writes a table of counts to the run's summary
A mirror in a guest group (linked_invitation_id) the guest's device holds no key for the host's wrap the mirror's location is whatever plaintext the host's row carried when the mirror was made; a host that has sealed no longer propagates place changes to guests (the mirroring logic was updated to match) — — Sydney
Logs — — Cloud Run logs carry request outcomes and error codes; never a group key, never a wrap, never opened text (our rule about what may never appear in a log) — —

What crosses a boundary and why it is allowed to: the group key crosses from an admin's device to our assistant server once per key version, inside TLS, inside that admin's authenticated request, and is discarded after KMS answers. The opened text crosses from our assistant server to the model provider inside a turn, under the assistant consent, exactly as the plaintext columns did before.

3.1 Which phone a request comes from (added 15 October 2026; assessed, not yet built)

Since this assessment was written, the same mechanism has come to cover far more than event notes and places: a task's words, shopping lists, baby notes, meal plans, ceremony and game text, Mail Sifter's notes and the Home brief are all opened the same way. Each of these is shown as locked on a phone that doesn't hold the group's key. Today our server opens all of them for any active member of the group, from any phone. The design below closes that difference.

How it's introduced. Installed apps don't send proofs yet. Our server will first accept requests without them and count how many arrive. Once the oldest app version still allowed to connect sends proofs, the check is switched on. Our privacy policy won't describe this until then.

What it costs. A member whose phone is waiting for the key gets "not available" from the assistant for tasks, calendar notes, shopping and the brief until an owner or admin shares the key or automatic delivery gives it. That matches what their screen already shows them. A shared display's assistant stops reading content under earlier keys, which the display doesn't hold. A request with a proof that doesn't match still causes one release of our copy before it is refused; it is recorded and counted against the hourly limit like any other.

What it gives. The assistant can't tell a member more than their phone can show. Stopping a phone's key delivery now also stops the assistant reading the group's encrypted content for that phone, and revoking a delivery stops it for anything under the group's next key. And someone holding our database's service key can no longer use the assistant to read a group's tasks and calendar a day at a time; they would need a phone that holds the key.

4. Legal basis and consents

5. Risks and mitigations

Risk Who is harmed Mitigation Structural or conventional? Residual
Somebody with the Supabase service key reads every family's appointment notes and where their children will be every member; every child named in a calendar the columns are ciphertext; the group key is wrapped under a KEK in another company's project; the service key opens neither structural (crypto + provider split) metadata — title, time, who — stays readable, by design
Somebody who compromises the GCP project holds the KEK and reads the calendar in bulk the same the KEK opens wraps, and the wraps are in Supabase behind RLS; GCP alone holds an anon key and a client that can reach exactly what one signed-in member can, one request at a time; there is no bulk path and no endpoint that takes an id and returns plaintext structural as to the bulk path (no service key on the assistant server is a deployment decision; the decrypter role is granted to the unwrapping identity only); conventional as to "no oracle" (pinned by an automated check) a container RCE can impersonate the unwrapper for the duration of a request it is handling, and read what that request's caller could read — see §8
A member of the group who is not an admin writes a kms wrap, or a wrap for an event group the group (its content becomes server-readable without an admin deciding) restrictive INSERT policy: wrap_kind = 'kms' requires is_group_admin(group_id) and is_standing_group(group_id) structural an admin can insert a bogus kms row and block the real one for that version (one per version, append-only); a rotation clears it. Their own group, their own assistant
The server keeps a group key between requests, so a later compromise reads earlier groups the group keys live in local variables of one call and are wiped; an automated check fails any code that would hold a key at module scope conventional, test-pinned —
A decrypt happens that nobody can later evidence the group; accountability generally the kms_unwraps row is written before the decrypt and the decrypt is refused if the write fails; the table is append-only by privilege (no UPDATE/DELETE grant for any role, service role included), readable only by the member the row names (from 30 September 2026; before that, also by the group's admins) structural as to append-only; conventional as to "before" (source order, asserted by the check) the row names a purpose from a closed list; which purpose a caller claims is what the caller says
A key is released outside any member's request (a bug that skips the record, or something other than our assistant server using the unwrapping identity) and nobody notices the group a nightly job holds Google's record of every release against our attempt counts and alerts on any release no attempt accounts for; Google's record sits in a bucket whose retention can be locked, and changes to its collection are alerted detective, not preventive; the comparison is code, run on a schedule that can be delayed one release can open many items in a request; a copied key needs no release; a member can add attempt rows of their own, which can hide an extra release in the count (never cause a false alert); the job is off until configured
A member's assistant is used to read the calendar at volume the group at most TIER2_UNWRAPS_PER_HOUR (default 60) unwraps per member per hour, counted from the audit table — shared across instances, unlike the in-memory limiter conventional (code), counted from a structural table the count is per member, not per group
A member, or someone signed in as one, has the assistant read content their phone shows as locked: a phone still waiting for the key, a phone whose delivery was stopped or revoked, an account taken over without any of its phones, or a session forged with our database's service key the group; every child named in its tasks and calendar (added 15 October 2026, not yet built) every opening needs a proof, computed from the key, that the request comes from a phone holding that version; checked against the unwrapped key before any item is opened (§3.1) structural once switched on (making the proof needs the key); conventional that every opening path checks it (one function, pinned by an automated check) until switched on, any active member's account can have the assistant read the group's Tier 2 content, whatever its phone holds (§8)
The placeholder Encrypted is read as content — by the model ("the location is Encrypted"), by an older version of the app, or by a guest group's mirror the member (an odd answer); a guest (a party with no place) openTier2Columns replaces an unopenable column with a sentence saying it is not available, never the placeholder; the client opens on the way into the store and marks a row it cannot open locked; a mirror keeps the last plaintext place it had conventional, test-pinned (by our automated tests) a guest whose mirror was made after the host sealed sees Encrypted as the place, and a host's later place changes do not reach guests — until a planned follow-up wraps for the guest group
An edit from a device that holds no key, or from an older version of the app, overwrites a sealed row's placeholder with plaintext the group (a field at rest in plaintext) a current app version refuses the notes/location part of an edit while it holds no key; the open path honours non-placeholder plaintext on a sealed row so a legacy edit is not lost, and the next keyed edit re-seals it conventional plaintext at rest for that field until re-sealed
A row the server itself wrote — an assistant-created event, a voice draft, an event group's founding event — is plaintext the group said plainly in the copy; sealed by the history job when a member's device next runs it conventional (copy, dated) a window per row
A child triggers a read of another family's notes a child the child-safe variant refuses the assistant to a child before any tool runs structural (restrictive policies on chat_sessions / chat_messages inserts; server-side isChildCaller, fail-closed) —

6. Structural versus conventional

Structural

Conventional, and what pins each

7. Best interests of the child

A family calendar is where a child's week is written down — where they will be, when, with whom, and the notes a parent keeps about it. Before this mechanism existed those notes were plaintext in a database we could read. After it, they are ciphertext at rest, and the only thing that can open them server-side is a request one of the group's own adults makes to an assistant that could already see them.

8. Residual risk we accept

  1. A container RCE is a decrypt capability for a request's duration. The runtime identity can mint a token as the unwrapper; code running inside the container during a member's request can therefore open what that member could see. It cannot enumerate: it holds an anon key and the caller's JWT, and RLS bounds it to that caller's groups. We accept this as the cost of the assistant reading anything at all; the alternative (a separate decrypt service) moves the same capability one hop away without removing it.
  2. A guest group sees Encrypted as the place of a party whose host sealed before the mirror was made, and a sealed host's place changes do not reach guests. A planned follow-up — wrapping for the guest group — is not built yet. Until it is, hosts of cross-group events are told nothing special; the invitation preview still shows the place because it is read from the host's row by a SECURITY DEFINER function at preview time, before the mirror exists.
  3. Server-written rows are plaintext until a device seals them — the assistant's own events, voice drafts, the founding event of an event group. The claim is dated for this reason.
  4. An admin can block their own group's wrap with a bogus kms row for a version. Recoverable by rotating. Not worth a second gate.
  5. The audit row names the member who triggered the read. It is a record of an act; an erasure sets it null (on delete set null), the row itself stays as the evidence the count exists to be.
  6. The opened text reaches the model provider, as it did before this mechanism existed, under the assistant consent and the provider's no-training terms. This change does not alter that and does not claim to.
  7. Task notes are not sealed and the copy says which columns are.
  8. Until the check in §3.1 is switched on, our server opens a group's encrypted tasks, calendar notes and other Tier 2 content for any active member's account, whatever phone it is used from (added 15 October 2026). That includes a phone still waiting for the key, a phone whose key delivery was stopped or revoked, an account taken over without any of its phones, and a session forged by someone holding our database's service key. Each read is still bounded: one request at a time, at most 60 unwraps an hour per member, with an audit row first. Once the check is on, this item closes, and a member waiting for the key gets "not available" from the assistant until a phone of theirs holds it.

9. Review trigger

10. Checklist against exposure draft s 38(2)

s 38(2) Requires Answered in
(a) nature, scope, context, flow and purposes §§1–3
(b) why collection is strictly necessary §2 (nothing new is collected; the wrap and the audit row are what make the read bounded and evidenced)
(c) lawful and fair means §4
(d) best interests of the child, with reasoning §7
(e) how the entity complies with the Code and the Act §4, §6
(f) risk of harm to children §5

s 38(3): this assessment was written before the database change and the code that implements it, and finished first. s 39: the register is published at https://groupapp.ai/privacy/impact-assessments/, since 9 September 2026.

11. Sources