← 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:
group_tasks.notes. It is inside thenotes_metacompatibility window: the legacy codec string carries the text and every version of the app before that window reads it, so sealing it means either closing the window or writing a placeholder into a codec. That decision is still open. The two options and their costs are written up separately; nothing here touches the column.- The privacy level itself (Standard / Locked). Today every standing group is Standard, so the wrap is written for every one. Locked is the absence of the wrap; this assessment says what that absence buys, for when that choice is offered.
- What reaches the model provider. The assistant already returned event notes and locations to
the model before this mechanism existed —
get_daily_scheduleselected*— under the existingassistantconsent. This mechanism changes what is at rest and who can open it, not what a turn discloses. That is covered in a separate assessment of what reaches the model provider.
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.
- Every request a phone makes to our assistant server carries a short proof for each of the group's keys that phone holds. The proof is computed on the phone from the key, the member's account, the group, the key's version and the current minute. The key never leaves the phone. A phone given only the narrower key for tasks and calendar can make the proof too, because it is computed from exactly the key our server's copy opens to. A shared display holds the group's current key, so it proves the current version.
- No proof for a version means nothing is opened under that version. Our server doesn't ask Google for the key or write an audit row; the content reads as "not available", as it already does for a group that has given our server no copy.
- A proof is checked against the key itself. With a proof, our server writes its audit row, unwraps its copy as it does now, and checks the proof against the key it has just unwrapped before it opens a single item. If it doesn't match, the key is wiped and nothing is opened. Because the check uses the key, a database record can't vouch for a phone, and a session forged by someone holding our database's service key can't make a proof.
- A proof is good for a few minutes, and only for the member, group and version it names, so a proof seen once can't be used by another account or reused later.
- It covers every place our server opens a group's encrypted content in answer to a member: the assistant's replies and the tools behind them, finding a task by its name or by its meaning, the Home brief, the baby summary, ceremonies and Mail Sifter's notes. Finding a task by its name counts as opening it. All of these already go through one function, and an automated check will fail a new path that opens content without the proof.
- Writing is unchanged. A member whose phone lacks the key can still ask the assistant to add a task. Our server encrypts the words as it does now, and that reveals nothing the member didn't type. Our server's own sorting of an email as it arrives involves no member and no phone, and is also unchanged.
- A request without a proof never writes the Home brief. The brief is written once a day for the whole group, from the group's tasks and calendar. Written from a request that couldn't open them, it would come out with gaps and then be shown to everyone. That request gets no brief, and the phone shows the brief as locked, as it already does.
- Each key version is proved separately. A phone proves every version it holds. Content under an earlier key the phone hasn't been given isn't opened for it, until the group's earlier keys reach that phone. The assistant then reads exactly what that phone could show.
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
assistant— the consent that already covers the assistant, and the turn that causes the read. Nothing new is sent to a model; the same tool returns the same columns. Withdrawal turns the assistant off, which turns the reads off.- Writing the wrap is an admin's act on the group's behalf, gated in the database
(a restrictive INSERT policy on
group_key_wraps:wrap_kind = 'kms'needsis_group_adminandis_standing_group). Today it is automatic for a Standard group — the admin device that mints or rotates the key writes the wrap in the same flow, and since 6 October 2026 an owner's or admin's device offers it again whenever it finds the current version without one — and the Devices screen shows a repair button when a version has no wrap. That choice between Standard and Locked is not offered yet, though it will be, announced to the group like other consent-class settings; until then the group is told on the Devices screen what the wrap is and who can open it (since 10 October 2026, that the assistant can still read event notes is shown, and the key kept separately from the database is one tap away under it). Noconsentsrow is written for this: there is no screen yet on which a person asks for it, and the ledger rule is that nothing writes agrantedrow a person did not just ask for. - A child account cannot trigger a read:
isChildCallerrefuses the assistant's chat and voice routes before any tool runs. A guardian can, in the ordinary way.
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
- The columns are ciphertext and the group key exists on devices and, wrapped under the KEK, in
group_key_wraps— never in plaintext at rest anywhere. - The KEK is in its own project with its own service account, and only
kms-unwrapperholdsroles/cloudkms.cryptoKeyDecrypter; the runtime identity holdscryptoKeyEncrypterandserviceAccountTokenCreatoron the unwrapper, nothing more. Removing that one binding is the kill switch for every Tier 2 read. - Our assistant server holds no
SUPABASE_SERVICE_ROLE_KEY, so "GCP alone" is a key and an RLS-bound client. group_key_wrapsis append-only by privilege; akmsrow is admin-only and standing-only by a restrictive policy; one per version by the existing unique constraint.kms_unwrapsis append-only by privilege,user_iddefaults toauth.uid(), insert requires active membership anduser_id = auth.uid(), and a member reads only rows naming them (the admin-wide read was removed on 30 September 2026). A row cannot be inserted with an outcome; the one later write is the outcome, once, on the caller's own row, through a function — there is still no UPDATE grant for any role.- The nightly check's database role may execute one function, which returns counts per minute and nothing that names a member, a group or a purpose; its cloud identity may read one log bucket.
seal_schedule_eventrefuses an already-sealed row, a non-member, an event group and a mirror; writes a fixed placeholder that is not a parameter;group_schedule_events_sealed_shaperefuses a row claiming to be encrypted with nothing sealed on it.- A child account cannot reach the assistant's chat route.
- (Added 15 October 2026, not yet built.) Once the check in §3.1 is switched on, our server opens content under a key version only for a request carrying a proof made with that version's key. Nothing our database records can stand in for it.
Conventional, and what pins each
- There is one reader and it cannot be given a record id.
openTier2Columns(client, groupId, rows, purpose)takes rows the caller already fetched through the RLS-bound client; there is no route that takes an id and returns plaintext. An automated check fails any part of the code outside the reader itself that references the KMS decrypt URL, the identity-token endpoint, or the unwrapper's credential, and any route whose path looks like a shortcut to plaintext. - Nothing caches a group key across requests — the same check refuses code that would hold a key in memory between requests.
- The audit row precedes the decrypt — asserted by source order and exercised by the check against a fake client that refuses the insert and a fake KMS that records whether it was called.
- The purpose union is closed in two places —
TIER2_PURPOSESand a database CHECK constraint; the check reads the database schema and compares them. - The impersonation binding (
runtime_impersonates_unwrapper) is what turns a container RCE into a decrypt capability for the duration of a request. That is a property of the design we accept (see §8), not something a test can remove. get_daily_schedulegoes through the reader, and degrades to a sentence — added by hand to the tool's generated definition and checked automatically, the same shape as the multi-day filters.- The same sealing algorithm is used on both sides — parity is pinned by a shared test file both the app and the server check against.
- The copy. The claim is phrased in one place only, and an automated test refuses "end-to-end", "zero-knowledge", "we cannot read" and an undated blanket claim.
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.
- What it gives a child: an appointment note about them is no longer readable by whoever holds the database. A stranger with a dump learns that "Thursday 3pm" exists; not where, not why.
- What it costs them: nothing new. The assistant's view of the calendar is unchanged, and a child account cannot ask it anything.
- The safer design would be Tier 1 — no server wrap at all. It was not chosen for Standard because the assistant's entire value on this surface is "what's on and where", and a Tier 1 calendar answers that with "encrypted". It is the design for Locked, which is why the wrap is a row that can be absent rather than a code path that can be switched.
- Where the interests diverge: a parent wants the assistant to know where the paediatrician is; the child, one day an adult, has an interest in that note not outliving its use. The audit row gives the member who asked a record of each time their own request asked for the key; the 30-day sweep on the assistant transcript already discards the conversation that asked. What is not built is any retention on the note itself — it lives as long as the event does, as it did before.
- What the child is told (s 33): the child-directed policy already says the adults in a group can see everything in it. This change alters nothing a child can see, so the child's page does not change.
8. Residual risk we accept
- 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.
- A guest group sees
Encryptedas 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. - 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.
- An admin can block their own group's wrap with a bogus
kmsrow for a version. Recoverable by rotating. Not worth a second gate. - 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. - The opened text reaches the model provider, as it did before this mechanism existed, under
the
assistantconsent and the provider's no-training terms. This change does not alter that and does not claim to. - Task notes are not sealed and the copy says which columns are.
- 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
- The privacy level (Standard / Locked) ships: §4 becomes a choice and this document is re-read against it.
- We decide whether to seal task notes: a second Tier 2 column means a second sealing path and a
second reader, and
openTier2Columnsgains a table. - A second
purposegets a writer (allocation,insight): today onlyassistant_turnhas one. - Any new server-side reader of
sealed_notes/sealed_location— including a regeneration of the calendar tools that the check does not catch. - A follow-up wraps for a guest group: residual 2 closes and the mirror row changes shape.
- A Tier 2 column is added to any other table.
- The check in §3.1 is switched on: residual 8 closes, and the privacy policy may then describe it.
- Any new way our server opens a group's encrypted content in answer to a member that doesn't pass through the proof check, or any proposal to let a database record stand in for the proof.
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
docs/encryption_plan.md§4 (the two-provider split), §5 (invariants 1, 3, 4), §11 (order of work) — revised alongside this mechanism.- Our internal specification for this mechanism and design notes revision 4 ("Tier 2 becomes device encrypts, server holds a second wrap").
terraform/bootstrap/projects.tf— the key project, the KEK,kms-unwrapper, the four IAM bindings.supabase/migrations/ws11_key_distribution.sql(8 September 2026) —group_key_wraps, wherewrap_kind = 'kms'was reserved, and the existing policies.supabase/migrations/ws13_chat_sealed.sql(8 September 2026) — the shape copied for the columns, the placeholder and the seal RPC.supabase/migrations/event_invitations.sql(8 September 2026) — the mirror and the propagation trigger replaced here.- The database change adding Tier 2 KMS (8 September 2026),
agent-server/src/tier2.ts,agent-server/checks/tier2.check.ts,lib/scheduleSealing.ts,lib/__tests__/scheduleSealing.test.ts,__tests__/tier2_kms_rls.test.ts. - Children's Online Privacy Code, exposure draft (
docs/sources/), ss 10, 33, 38, 39.