Skip to main content

max / makenotwork

Log follower exports, so an erasure can name who to contact 159a7a20, option b, decided 2026-08-30. POST /api/export/followers streams a creator's whole follower and project-subscriber set, addresses included, and recorded nothing. site-docs/public/legal/mailing-list-data-processing.md promises a subscriber that an erasure reaches an exported copy, and that promise rested on a person remembering who had exported. No addresses and no list id. The endpoint is AuthUser-scoped and exports everything the creator has, so there is no list to name -- and holding a second copy of anyone's address to answer a question about copies of addresses is what this is meant to avoid. creators_to_contact intersects relationships the database already has against export timestamps and reads no address at all: the creators this person follows, and of those, the ones who exported after the relationship began. An export that ran before the follow carried nobody. The request and not the completion. The export streams from a spawned task, so a run can end partway, and a partial file still means addresses left the building -- which is the only thing the erasure follow-up cares about. Recording the completion would miss exactly the runs most likely to have gone wrong. The counts are nullable and a failed count does not refuse the export. A creator is entitled to their own data and a privacy record that can block a lawful request is the worse failure, so the row is written either way and a missing number says "we could not say how big" rather than claiming zero. ACCEPTED BLIND SPOT, decided rather than discovered: db::follows hard-deletes on unfollow, so somebody who has already unfollowed leaves no relationship row to intersect and this cannot see them. Answering that needs a stored set of exported addresses, hashed or otherwise, and that second copy was declined. It is written into the migration and the module so the next reader does not quietly add one.
Author: Max Johnson <me@maxj.phd> · 2026-08-30 23:26 UTC
Signed with PGP, not checked
Commit: afd71c081acd54ead4fd293bb6a5c29b16fc2441
Parent: 59af29f
8 files changed, +312 insertions, -16 deletions
M server/Cargo.lock +16 -16
@@ -10759,6 +10759,22 @@
10759 10759 "pkg-config",
10760 10760 ]
10761 10761
10762 + [[patch.unused]]
10763 + name = "quasi-immediate"
10764 + version = "0.91.1"
10765 +
10766 + [[patch.unused]]
10767 + name = "quasi-notifs"
10768 + version = "0.91.1"
10769 +
10770 + [[patch.unused]]
10771 + name = "quasi-store"
10772 + version = "0.1.0"
10773 +
10774 + [[patch.unused]]
10775 + name = "quasi-tauri"
10776 + version = "0.91.1"
10777 +
10762 10778 [[patch.unused]]
10763 10779 name = "kberg"
10764 10780 version = "0.1.0"
@@ -10778,19 +10794,3 @@
10778 10794 [[patch.unused]]
10779 10795 name = "synckit-config"
10780 10796 version = "0.2.0"
10781 -
10782 - [[patch.unused]]
10783 - name = "quasi-immediate"
10784 - version = "0.91.1"
10785 -
10786 - [[patch.unused]]
10787 - name = "quasi-notifs"
10788 - version = "0.91.1"
10789 -
10790 - [[patch.unused]]
10791 - name = "quasi-store"
10792 - version = "0.1.0"
10793 -
10794 - [[patch.unused]]
10795 - name = "quasi-tauri"
10796 - version = "0.91.1"
@@ -243,6 +243,27 @@
243 243 /// than materializing every follower in one query, and so the per-row email
244 244 /// `EXISTS` reveal runs only for a bounded page at a time (ultra-fuzz Run 4 S1).
245 245 /// Stable `(created_at, follower_id)` ordering keeps OFFSET batches consistent.
246 + /// How many rows the follower export will page through.
247 + ///
248 + /// The same set `get_followers_for_export_page` walks, counted rather than
249 + /// paged. Asked once before the export starts, so `follower_exports` can record
250 + /// what was about to be handed over (`159a7a20`).
251 + #[tracing::instrument(skip_all)]
252 + pub(crate) async fn count_followers_for_export(pool: &PgPool, user_id: UserId) -> Result<i64> {
253 + let count = sqlx::query_scalar::<_, i64>(
254 + r"
255 + SELECT COUNT(*)
256 + FROM follows f
257 + WHERE (f.target_type = 'user' AND f.target_id = $1)
258 + OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1))
259 + ",
260 + )
261 + .bind(user_id)
262 + .fetch_one(pool)
263 + .await?;
264 + Ok(count)
265 + }
266 +
246 267 pub(crate) async fn get_followers_for_export_page(
247 268 pool: &PgPool,
248 269 user_id: UserId,
@@ -25,6 +25,7 @@
25 25 pub(crate) mod email_suppressions;
26 26 mod enums;
27 27 pub(crate) mod fan_plus;
28 + pub mod follower_exports; // pub so the integration test crate can drive the erasure query directly
28 29 pub(crate) mod follows;
29 30 pub mod gallery_images;
30 31 pub mod git_access_tokens;
@@ -709,6 +709,29 @@
709 709 /// One page of a creator's project subscribers for CSV export, newest first.
710 710 /// Paginated for bounded-memory streaming (ultra-fuzz Run 4 S1); stable
711 711 /// `(created_at, id)` ordering keeps OFFSET batches consistent.
712 + /// How many rows the subscriber half of the follower export will page through.
713 + ///
714 + /// The same set `get_project_subscribers_for_export_page` walks. Asked once
715 + /// before the export starts, so `follower_exports` can record what was about to
716 + /// be handed over (`159a7a20`).
717 + #[tracing::instrument(skip_all)]
718 + pub(crate) async fn count_project_subscribers_for_export(
719 + pool: &PgPool,
720 + user_id: UserId,
721 + ) -> Result<i64> {
722 + let count = sqlx::query_scalar::<_, i64>(
723 + r"
724 + SELECT COUNT(*)
725 + FROM subscriptions s
726 + WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1)
727 + ",
728 + )
729 + .bind(user_id)
730 + .fetch_one(pool)
731 + .await?;
732 + Ok(count)
733 + }
734 +
712 735 pub(crate) async fn get_project_subscribers_for_export_page(
713 736 pool: &PgPool,
714 737 user_id: UserId,
@@ -554,3 +554,69 @@
554 554 "a legacy NULL-size file must still be exported (size resolved via S3 HEAD)"
555 555 );
556 556 }
557 +
558 + /// `159a7a20`. The promise in `mailing-list-data-processing.md` -- that an
559 + /// erasure reaches an exported copy -- rested on somebody remembering who had
560 + /// exported. This is the row that makes it a query.
561 + #[tokio::test]
562 + async fn a_follower_export_leaves_a_row_an_erasure_can_find() {
563 + let mut h = TestHarness::new().await;
564 + let seller_id = h
565 + .create_creator_with_item("erasureseller", "digital", 1000)
566 + .await
567 + .user_id;
568 +
569 + let follower_id = create_buyer(&h.db).await;
570 + let seller_uuid: uuid::Uuid = seller_id.into();
571 + insert_follow(&h.db, follower_id, seller_uuid).await;
572 +
573 + let resp = h.client.post_form("/api/export/followers", "").await;
574 + assert_eq!(resp.status, 200, "export failed: {}", resp.text);
575 +
576 + // The follower this creator was about to be handed.
577 + let counted = sqlx::query_as::<_, (Option<i64>, Option<i64>)>(
578 + "SELECT follower_count, subscriber_count FROM follower_exports WHERE user_id = $1",
579 + )
580 + .bind(seller_id)
581 + .fetch_one(&h.db)
582 + .await
583 + .expect("the export left a row");
584 + assert_eq!(counted.0, Some(1), "one follower was about to be exported");
585 + assert_eq!(counted.1, Some(0));
586 +
587 + // And the erasure query names that creator without reading an address out
588 + // of the export: it intersects the relationship with the export's time.
589 + let contact = makenotwork::db::follower_exports::creators_to_contact(&h.db, follower_id)
590 + .await
591 + .expect("the erasure query runs");
592 + assert_eq!(contact.len(), 1, "one creator to write to");
593 + assert_eq!(contact[0].creator_id, seller_id);
594 + assert_eq!(contact[0].exports, 1);
595 + }
596 +
597 + /// An export that ran before the follow could not have carried this person, so
598 + /// it is not a creator to write to.
599 + #[tokio::test]
600 + async fn an_export_that_predates_the_follow_is_not_a_creator_to_contact() {
601 + let mut h = TestHarness::new().await;
602 + let seller_id = h
603 + .create_creator_with_item("earlyseller", "digital", 1000)
604 + .await
605 + .user_id;
606 +
607 + // The export happens first, with nobody following.
608 + let resp = h.client.post_form("/api/export/followers", "").await;
609 + assert_eq!(resp.status, 200, "export failed: {}", resp.text);
610 +
611 + let follower_id = create_buyer(&h.db).await;
612 + let seller_uuid: uuid::Uuid = seller_id.into();
613 + insert_follow(&h.db, follower_id, seller_uuid).await;
614 +
615 + let contact = makenotwork::db::follower_exports::creators_to_contact(&h.db, follower_id)
616 + .await
617 + .expect("the erasure query runs");
618 + assert!(
619 + contact.is_empty(),
620 + "an export before the follow carried nobody: {contact:?}"
621 + );
622 + }
@@ -723,6 +723,30 @@
723 723 let pool = db.clone();
724 724 let uid = user.id;
725 725
726 + // Noted before a byte leaves, because the promise in
727 + // `site-docs/public/legal/mailing-list-data-processing.md` -- that an
728 + // erasure reaches an exported copy -- rested on somebody remembering who
729 + // had exported (`159a7a20`).
730 + //
731 + // The request rather than the completion: this streams from a spawned task
732 + // below, so a run can end partway, and a partial file still means addresses
733 + // left the building. Recording the completion would miss exactly the runs
734 + // most likely to have gone wrong.
735 + //
736 + // A count that cannot be read does not refuse the export. The creator is
737 + // entitled to their own data, and a privacy record that can block a lawful
738 + // request is the worse failure -- so the row is written with the number
739 + // missing, which says "this happened and we could not say how big" rather
740 + // than claiming zero.
741 + let followers = db::follows::count_followers_for_export(&db, uid).await.ok();
742 + let subscribers = db::subscriptions::count_project_subscribers_for_export(&db, uid)
743 + .await
744 + .ok();
745 + if let Err(error) = db::follower_exports::record(&db, uid, followers, subscribers).await {
746 + tracing::error!(error = ?error, user_id = %uid,
747 + "follower export not recorded; an erasure will not see this one");
748 + }
749 +
726 750 // Two-section CSV (followers, then subscribers); each section pages
727 751 // independently so the whole thing streams in bounded batches (Run 4 S1).
728 752 let (tx, rx) = mpsc::channel::<Bytes>(4);
@@ -1,0 +1,45 @@
1 + -- Who exported their followers, and when.
2 + --
3 + -- `POST /api/export/followers` streams a creator's whole follower and
4 + -- project-subscriber set, addresses included, and recorded nothing.
5 + -- `site-docs/public/legal/mailing-list-data-processing.md` promises a
6 + -- subscriber that an erasure reaches an exported copy, and that promise rested
7 + -- on a person remembering who had exported. This is the row that makes it a
8 + -- query (`159a7a20`, option b, 2026-08-30).
9 + --
10 + -- No addresses and no list id. The endpoint is `AuthUser`-scoped and exports
11 + -- everything the creator has, so there is no list to name -- and holding a
12 + -- second copy of anyone's address to answer a question about copies of
13 + -- addresses is the thing this is meant to avoid.
14 + --
15 + -- The erasure query is: the creators this subscriber follows or subscribes to,
16 + -- intersected with the creators who exported after that relationship began.
17 + --
18 + -- ACCEPTED BLIND SPOT, decided rather than discovered: `db::follows` hard-deletes
19 + -- on unfollow, so a subscriber who has already unfollowed leaves no relationship
20 + -- row to intersect against and this cannot see them. Answering that needs a
21 + -- stored set of exported addresses, hashed or otherwise, and that second copy
22 + -- was declined. Do not quietly add one; reopen the decision if the case turns
23 + -- up in practice.
24 + CREATE TABLE IF NOT EXISTS follower_exports (
25 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
26 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
27 + -- What the export was about to hand over. Counted at request time, because
28 + -- the export streams from a spawned task and a run that ends partway still
29 + -- means addresses left the building -- which is the only thing the erasure
30 + -- follow-up cares about.
31 + --
32 + -- Nullable, and NULL means "this happened and we could not say how big".
33 + -- A count that fails to read must not refuse the export -- the creator is
34 + -- entitled to their own data, and a privacy record that can block a lawful
35 + -- request is the worse failure -- so the row is written either way, and a
36 + -- missing number says so rather than claiming zero.
37 + follower_count BIGINT CHECK (follower_count IS NULL OR follower_count >= 0),
38 + subscriber_count BIGINT CHECK (subscriber_count IS NULL OR subscriber_count >= 0),
39 + requested_at TIMESTAMPTZ NOT NULL DEFAULT now()
40 + );
41 +
42 + -- The erasure query asks "which of these creators exported since a given
43 + -- moment", so the moment is the second column.
44 + CREATE INDEX IF NOT EXISTS idx_follower_exports_user
45 + ON follower_exports (user_id, requested_at DESC);
@@ -1,0 +1,116 @@
1 + //! Who exported their followers, and when.
2 + //!
3 + //! `159a7a20`, option b, decided 2026-08-30. `POST /api/export/followers`
4 + //! streams a creator's whole follower and project-subscriber set, addresses
5 + //! included, and recorded nothing. `site-docs/public/legal/mailing-list-data-processing.md`
6 + //! promises a subscriber that an erasure reaches an exported copy, and that
7 + //! promise rested on a person remembering who had exported.
8 + //!
9 + //! # No addresses, and no list
10 + //!
11 + //! The endpoint is `AuthUser`-scoped and exports everything the creator has, so
12 + //! there is no list to name. And holding a second copy of anyone's address to
13 + //! answer a question *about* copies of addresses is the thing this exists to
14 + //! avoid: [`creators_to_contact`] intersects relationships the database already
15 + //! has against export timestamps, and reads no address at all.
16 +
17 + use chrono::{DateTime, Utc};
18 + use sqlx::PgPool;
19 +
20 + use super::id_types::UserId;
21 + use crate::error::Result;
22 +
23 + /// Note that a creator asked for their followers.
24 + ///
25 + /// The request and not the completion. The export streams from a spawned task,
26 + /// so a run can end partway -- and a logged request that produced a partial
27 + /// file still means addresses left the building, which is the only thing the
28 + /// erasure follow-up cares about. Recording the completion instead would miss
29 + /// exactly the runs most likely to have gone wrong.
30 + #[tracing::instrument(skip_all)]
31 + /// `None` for a count that could not be read: the row still has to be written,
32 + /// so the number says "we could not say" rather than claiming zero.
33 + pub async fn record(
34 + pool: &PgPool,
35 + user_id: UserId,
36 + follower_count: Option<i64>,
37 + subscriber_count: Option<i64>,
38 + ) -> Result<()> {
39 + sqlx::query(
40 + "INSERT INTO follower_exports (user_id, follower_count, subscriber_count) \
41 + VALUES ($1, $2, $3)",
42 + )
43 + .bind(user_id)
44 + .bind(follower_count)
45 + .bind(subscriber_count)
46 + .execute(pool)
47 + .await?;
48 + Ok(())
49 + }
50 +
51 + /// The creators an erasing subscriber's data may have reached, and when.
52 + ///
53 + /// The creators this person follows or subscribes to, intersected with the ones
54 + /// who exported *after* that relationship began. An export that ran before the
55 + /// follow could not have carried them, so it is not a creator to write to.
56 + ///
57 + /// `latest` is the most recent qualifying export, which is what an operator
58 + /// puts in the mail. Ordered by it, newest first: the creator most likely to
59 + /// still have the file open is the one to reach first.
60 + ///
61 + /// # What this cannot see, and it was decided rather than discovered
62 + ///
63 + /// [`super::follows`] hard-deletes on unfollow, so a subscriber who has already
64 + /// unfollowed leaves no relationship row to intersect against. Answering that
65 + /// case needs a stored set of exported addresses, hashed or otherwise, and Max
66 + /// declined the second copy on 2026-08-30. Reopen that decision if the case
67 + /// turns up in practice; do not quietly add the copy.
68 + #[tracing::instrument(skip_all)]
69 + pub async fn creators_to_contact(pool: &PgPool, subscriber_id: UserId) -> Result<Vec<Contacted>> {
70 + let rows = sqlx::query_as::<_, Contacted>(
71 + r"
72 + WITH related AS (
73 + SELECT f.target_id AS creator_id, f.created_at AS since
74 + FROM follows f
75 + WHERE f.follower_id = $1 AND f.target_type = 'user'
76 + UNION ALL
77 + SELECT p.user_id AS creator_id, f.created_at AS since
78 + FROM follows f
79 + JOIN projects p ON p.id = f.target_id
80 + WHERE f.follower_id = $1 AND f.target_type = 'project'
81 + )
82 + SELECT
83 + r.creator_id AS creator_id,
84 + u.username AS username,
85 + u.email AS email,
86 + MAX(e.requested_at) AS latest,
87 + COUNT(e.id) AS exports
88 + FROM related r
89 + JOIN follower_exports e
90 + ON e.user_id = r.creator_id AND e.requested_at >= r.since
91 + JOIN users u ON u.id = r.creator_id
92 + GROUP BY r.creator_id, u.username, u.email
93 + ORDER BY latest DESC
94 + ",
95 + )
96 + .bind(subscriber_id)
97 + .fetch_all(pool)
98 + .await?;
99 + Ok(rows)
100 + }
101 +
102 + /// One creator an erasure has to reach.
103 + #[derive(Debug, Clone, sqlx::FromRow)]
104 + pub struct Contacted {
105 + /// Who to write to.
106 + pub creator_id: UserId,
107 + /// Their handle, for the operator reading the list.
108 + pub username: String,
109 + /// Where to write.
110 + pub email: String,
111 + /// The most recent export that could have carried this subscriber.
112 + pub latest: DateTime<Utc>,
113 + /// How many qualifying exports they ran. A creator who exports weekly is a
114 + /// different follow-up from one who exported once.
115 + pub exports: i64,
116 + }