Ask: confirm there is no plaintext anywhere it should not be. Prompted by code
references found while reading.
⚠️Most "plaintext" in the code is legitimate and should stay.Content.parse(plaintext:)
and friends name the decrypted message inside the app, which has to exist in memory to be
shown. Grepping for the word will mostly find those. The question is narrower and worth
stating precisely: what is written to disk, sent to a server, or left behind after an
overwrite.
Concrete leads, found 2026-09-01 — not hypothetical
1. Plaintext group rosters persist for unupgraded groups. Migrations.swift:459-463 says so outright: "⚠️ That means plaintext rosters persist for
unupgraded groups" — only the owner's client can write the encrypted blob, so a group
nobody has upgraded still has its member list in the clear on the relay. Production today: group_tags 2 live rows, groups 1 live. Small, but the mechanism is what matters, and
there is no forcing function that ever completes the migration.
2. Old row versions keep plaintext after an overwrite. Migrations.swift:411-423: "on Postgres the old row versions still hold the plaintext".
An UPDATE that blanks a column does not erase the previous tuple until vacuumed — so
"we deleted the plaintext" is true of the live row and false of the heap. Production right
now: users has 20 dead tuples against 2 live, groups 7 dead against 1, group_members 9 dead against 0. Whatever those rows held is still on disk. VACUUM (FULL) is the only thing that actually reclaims it; a plain VACUUM marks space
reusable without overwriting.
3. attachments — 30 rows, provenance unverified.
The E2E path uploads a sealed blob the relay cannot open. The legacy @photo path is
explicitly NOT end-to-end encrypted — /get and the landing page both say the server
stores it and the link carries an unguessable share token. Which of the 30 came from
which path has not been checked. If any are legacy-path uploads, they are readable by
whoever runs the relay, which is the documented behaviour but worth knowing the count of.
4. messages (the legacy plaintext table) is EMPTY — 0 live, 0 dead. Good, and worth
recording so it is not re-investigated.
5. Group metadata is plaintext by design — Models.swift:140. Names were removed from
the server (see /metadata), but the boundary of what remains should be stated in one
place rather than inferred from three.
What "done" looks like
A written inventory: every place message content, member lists, or user files come to rest,
and for each — encrypted or not, by what key, and readable by whom. Then the gaps become
decisions rather than discoveries.
⚠️ Two things NOT to change under the current freeze without a decision: the legacy @photo path is documented public behaviour, and VACUUM FULL takes an exclusive lock and
rewrites the table.
⚠️ Client side is in scope too, and one thing is already known: history is persisted
AES-GCM under a ThisDeviceOnly Keychain key, and as of 2026-09-01 a plain sign-out KEEPS
it. See flamenet-relay#16.
**Ask:** confirm there is no plaintext anywhere it should not be. Prompted by code
references found while reading.
⚠️ **Most "plaintext" in the code is legitimate and should stay.** `Content.parse(plaintext:)`
and friends name the *decrypted* message inside the app, which has to exist in memory to be
shown. Grepping for the word will mostly find those. The question is narrower and worth
stating precisely: **what is written to disk, sent to a server, or left behind after an
overwrite.**
## Concrete leads, found 2026-09-01 — not hypothetical
**1. Plaintext group rosters persist for unupgraded groups.**
`Migrations.swift:459-463` says so outright: *"⚠️ That means plaintext rosters persist for
unupgraded groups"* — only the owner's client can write the encrypted blob, so a group
nobody has upgraded still has its member list in the clear on the relay. Production today:
`group_tags` 2 live rows, `groups` 1 live. Small, but the mechanism is what matters, and
there is no forcing function that ever completes the migration.
**2. Old row versions keep plaintext after an overwrite.**
`Migrations.swift:411-423`: *"on Postgres the old row versions still hold the plaintext"*.
An `UPDATE` that blanks a column does not erase the previous tuple until vacuumed — so
"we deleted the plaintext" is true of the live row and false of the heap. Production right
now: `users` has **20 dead tuples** against 2 live, `groups` 7 dead against 1,
`group_members` 9 dead against 0. Whatever those rows held is still on disk.
`VACUUM (FULL)` is the only thing that actually reclaims it; a plain `VACUUM` marks space
reusable without overwriting.
**3. `attachments` — 30 rows, provenance unverified.**
The E2E path uploads a sealed blob the relay cannot open. The legacy `@photo` path is
explicitly NOT end-to-end encrypted — `/get` and the landing page both say the server
stores it and the link carries an unguessable share token. **Which of the 30 came from
which path has not been checked.** If any are legacy-path uploads, they are readable by
whoever runs the relay, which is the documented behaviour but worth knowing the count of.
**4. `messages` (the legacy plaintext table) is EMPTY** — 0 live, 0 dead. Good, and worth
recording so it is not re-investigated.
**5. Group metadata is plaintext by design** — `Models.swift:140`. Names were removed from
the server (see `/metadata`), but the boundary of what remains should be stated in one
place rather than inferred from three.
## What "done" looks like
A written inventory: every place message content, member lists, or user files come to rest,
and for each — encrypted or not, by what key, and readable by whom. Then the gaps become
decisions rather than discoveries.
⚠️ Two things NOT to change under the current freeze without a decision: the legacy
`@photo` path is documented public behaviour, and `VACUUM FULL` takes an exclusive lock and
rewrites the table.
⚠️ Client side is in scope too, and one thing is already known: history is persisted
AES-GCM under a ThisDeviceOnly Keychain key, and as of 2026-09-01 a plain sign-out KEEPS
it. See flamenet-relay#16.
Related: #16 (broader security review), #15 (Postgres superuser).
Inventory done, measured on production 2026-09-04. Written up in docs/PLAINTEXT.md (!46). Three of your four leads were clean; the second was real and is fixed.
1. Plaintext group rosters — clean, but only just. Both live groups are sealed (roster_blob present, roster_version = 1) and group_members holds 0 live rows. ⚠️ It held 9 dead tuples — the plaintext member rows, deleted from the live table and still in the heap. Lead 1 looked clean only because lead 2 was not.
2. Old row versions — REAL. 197 dead tuples, now 0.buddies 19 against 2 live, conversation_state 15/2, notifications 10/2, group_members 9/0, groups 7/2, users 7/6.
🔴The root cause is an autovacuum default that is worth knowing beyond this issue. Autovacuum triggers at 50 dead rows + 20% of the table. A two-row table therefore needs fifty dead tuples before it is ever looked at — so the SMALLEST tables, which here are precisely the ones holding account and contact data, were never vacuumed at all. buddies had an empty last_autovacuum and had been accumulating since the table existed.
Fixed in two parts, because neither is sufficient alone:
per-table autovacuum_vacuum_threshold=5, scale_factor=0.0 on every table holding user data — bounds accumulation;
messenger-vacuum.timer (Sundays, /srv/prod/monitoring/messenger-vacuum.sh) — this is the part that erases. Plain autovacuum only marks space reusable; the bytes stay until something overwrites them. Only VACUUM FULL rewrites.
3. Attachments — clean, and better than the issue assumed. The table is token, bytes, created_at with no content column at all. The blobs were checked directly: of 20 files on disk, zero begin with a recognisable format magic (JPEG/PNG/GIF/PDF/MP4/WebM/RIFF) — all opaque. And the legacy non-E2E @photo path no longer exists in the code: there is exactly one upload route, POST /e2e/attachments.
4. messages — empty. 0 live, 0 dead.
⚠️What this does not cover, stated rather than implied: the WAL archive and base backups hold those old tuples and the vacuum does not reach them. They are encrypted, so it is bounded rather than open — but retention, not the vacuum, is what decides when it ends. And ~13 attachments live in B2 rather than on disk and were not magic-scanned; they travel the same sealed path, so that is an expectation, not a measurement.
**Inventory done, measured on production 2026-09-04.** Written up in `docs/PLAINTEXT.md` (!46). Three of your four leads were clean; the second was real and is fixed.
**1. Plaintext group rosters — clean, but only just.** Both live groups are sealed (`roster_blob` present, `roster_version = 1`) and `group_members` holds 0 live rows. ⚠️ It held **9 dead tuples** — the plaintext member rows, deleted from the live table and still in the heap. Lead 1 looked clean only because lead 2 was not.
**2. Old row versions — REAL. 197 dead tuples, now 0.** `buddies` 19 against 2 live, `conversation_state` 15/2, `notifications` 10/2, `group_members` 9/0, `groups` 7/2, `users` 7/6.
🔴 **The root cause is an autovacuum default that is worth knowing beyond this issue.** Autovacuum triggers at `50 dead rows + 20% of the table`. A two-row table therefore needs fifty dead tuples before it is ever looked at — so the SMALLEST tables, which here are precisely the ones holding account and contact data, were **never vacuumed at all**. `buddies` had an empty `last_autovacuum` and had been accumulating since the table existed.
Fixed in two parts, because neither is sufficient alone:
- per-table `autovacuum_vacuum_threshold=5, scale_factor=0.0` on every table holding user data — bounds accumulation;
- `messenger-vacuum.timer` (Sundays, `/srv/prod/monitoring/messenger-vacuum.sh`) — **this is the part that erases.** Plain autovacuum only marks space reusable; the bytes stay until something overwrites them. Only `VACUUM FULL` rewrites.
**3. Attachments — clean, and better than the issue assumed.** The table is `token, bytes, created_at` with **no content column at all**. The blobs were checked directly: of 20 files on disk, **zero** begin with a recognisable format magic (JPEG/PNG/GIF/PDF/MP4/WebM/RIFF) — all opaque. And the legacy non-E2E `@photo` path no longer exists in the code: there is exactly one upload route, `POST /e2e/attachments`.
**4. `messages` — empty.** 0 live, 0 dead.
⚠️ **What this does not cover, stated rather than implied:** the WAL archive and base backups hold those old tuples and the vacuum does not reach them. They are encrypted, so it is bounded rather than open — but **retention, not the vacuum, is what decides when it ends**. And ~13 attachments live in B2 rather than on disk and were not magic-scanned; they travel the same sealed path, so that is an expectation, not a measurement.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Ask: confirm there is no plaintext anywhere it should not be. Prompted by code
references found while reading.
⚠️ Most "plaintext" in the code is legitimate and should stay.
Content.parse(plaintext:)and friends name the decrypted message inside the app, which has to exist in memory to be
shown. Grepping for the word will mostly find those. The question is narrower and worth
stating precisely: what is written to disk, sent to a server, or left behind after an
overwrite.
Concrete leads, found 2026-09-01 — not hypothetical
1. Plaintext group rosters persist for unupgraded groups.
Migrations.swift:459-463says so outright: "⚠️ That means plaintext rosters persist forunupgraded groups" — only the owner's client can write the encrypted blob, so a group
nobody has upgraded still has its member list in the clear on the relay. Production today:
group_tags2 live rows,groups1 live. Small, but the mechanism is what matters, andthere is no forcing function that ever completes the migration.
2. Old row versions keep plaintext after an overwrite.
Migrations.swift:411-423: "on Postgres the old row versions still hold the plaintext".An
UPDATEthat blanks a column does not erase the previous tuple until vacuumed — so"we deleted the plaintext" is true of the live row and false of the heap. Production right
now:
usershas 20 dead tuples against 2 live,groups7 dead against 1,group_members9 dead against 0. Whatever those rows held is still on disk.VACUUM (FULL)is the only thing that actually reclaims it; a plainVACUUMmarks spacereusable without overwriting.
3.
attachments— 30 rows, provenance unverified.The E2E path uploads a sealed blob the relay cannot open. The legacy
@photopath isexplicitly NOT end-to-end encrypted —
/getand the landing page both say the serverstores it and the link carries an unguessable share token. Which of the 30 came from
which path has not been checked. If any are legacy-path uploads, they are readable by
whoever runs the relay, which is the documented behaviour but worth knowing the count of.
4.
messages(the legacy plaintext table) is EMPTY — 0 live, 0 dead. Good, and worthrecording so it is not re-investigated.
5. Group metadata is plaintext by design —
Models.swift:140. Names were removed fromthe server (see
/metadata), but the boundary of what remains should be stated in oneplace rather than inferred from three.
What "done" looks like
A written inventory: every place message content, member lists, or user files come to rest,
and for each — encrypted or not, by what key, and readable by whom. Then the gaps become
decisions rather than discoveries.
⚠️ Two things NOT to change under the current freeze without a decision: the legacy
@photopath is documented public behaviour, andVACUUM FULLtakes an exclusive lock andrewrites the table.
⚠️ Client side is in scope too, and one thing is already known: history is persisted
AES-GCM under a ThisDeviceOnly Keychain key, and as of 2026-09-01 a plain sign-out KEEPS
it. See flamenet-relay#16.
Related: #16 (broader security review), #15 (Postgres superuser).
mentioned in issue #18
mentioned in issue #19
mentioned in issue #21
mentioned in issue flamenet-messenger-ios#25
Inventory done, measured on production 2026-09-04. Written up in
docs/PLAINTEXT.md(!46). Three of your four leads were clean; the second was real and is fixed.1. Plaintext group rosters — clean, but only just. Both live groups are sealed (
roster_blobpresent,roster_version = 1) andgroup_membersholds 0 live rows. ⚠️ It held 9 dead tuples — the plaintext member rows, deleted from the live table and still in the heap. Lead 1 looked clean only because lead 2 was not.2. Old row versions — REAL. 197 dead tuples, now 0.
buddies19 against 2 live,conversation_state15/2,notifications10/2,group_members9/0,groups7/2,users7/6.🔴 The root cause is an autovacuum default that is worth knowing beyond this issue. Autovacuum triggers at
50 dead rows + 20% of the table. A two-row table therefore needs fifty dead tuples before it is ever looked at — so the SMALLEST tables, which here are precisely the ones holding account and contact data, were never vacuumed at all.buddieshad an emptylast_autovacuumand had been accumulating since the table existed.Fixed in two parts, because neither is sufficient alone:
autovacuum_vacuum_threshold=5, scale_factor=0.0on every table holding user data — bounds accumulation;messenger-vacuum.timer(Sundays,/srv/prod/monitoring/messenger-vacuum.sh) — this is the part that erases. Plain autovacuum only marks space reusable; the bytes stay until something overwrites them. OnlyVACUUM FULLrewrites.3. Attachments — clean, and better than the issue assumed. The table is
token, bytes, created_atwith no content column at all. The blobs were checked directly: of 20 files on disk, zero begin with a recognisable format magic (JPEG/PNG/GIF/PDF/MP4/WebM/RIFF) — all opaque. And the legacy non-E2E@photopath no longer exists in the code: there is exactly one upload route,POST /e2e/attachments.4.
messages— empty. 0 live, 0 dead.⚠️ What this does not cover, stated rather than implied: the WAL archive and base backups hold those old tuples and the vacuum does not reach them. They are encrypted, so it is bounded rather than open — but retention, not the vacuum, is what decides when it ends. And ~13 attachments live in B2 rather than on disk and were not magic-scanned; they travel the same sealed path, so that is an expectation, not a measurement.