Skip to main content

max / makenotwork

Alerts that repeat until someone confirms they read them Some messages must be seen, not merely sent. A single email that landed in spam is indistinguishable from one that landed and was ignored, and for a few alerts that difference is the whole point. The first case, and the one that prompted this: a creator's Stripe account changes settlement currency. Every price they have already set is stored as a plain number, so a price that was 10 GBP is now 10 EUR. Nothing here converts it, because guessing an exchange rate on someone's behalf is not ours to do, and only they can decide the new numbers. Until now that fired a wam ticket and said nothing at all to the creator, on the grounds that no mechanism existed for a message that keeps asking. The shape: a caller opens a row, the scheduler sends it and keeps sending it weekly, and pressing the button on the linked page stops it. Nothing sends from the opening call, so the first message and the repeats travel one path and cannot drift apart, and a caller that forgets to send still gets the alert delivered. Four things worth knowing about the design: Acknowledging takes a POST. Email verification gets away with acting on a bare GET; this must not. The entire value of the row is evidence a person read the message, and prefetchers and corporate mail scanners follow GETs. A bare GET would record attention that never happened, which is worse than recording nothing: it would silence exactly the alerts nobody read. The link token is derived, not stored. An alert is mailed up to four times over a month and every copy should keep working. A random token would have to be re-minted per send, killing last month's link, or held in plaintext so later sends could reproduce it. An HMAC over the row's own identity avoids both, and it is the same construction the unsubscribe links already use. It escalates rather than nagging forever. After four unanswered messages the sending stops and a wam ticket asks a person to make contact. A loop that runs for a year unattended is not an alert, it is a filter rule. It runs every tick, not daily. The weekly cadence is in the query's WHERE; how often the job wakes decides only how long something waits before the first message about it, and a day of that is too long when the alert is that a catalogue is mispriced right now. The mail is a new OperationalKind. Unopt-outable by definition, and the narrowest kind of that, because the recipient ends it themselves. Verified against a scratch database with every migration applied: open is idempotent while unacknowledged, an acknowledged row stops being due and stops blocking a fresh occurrence of the same thing, the week boundary is where it should be, and the kind constraint rejects what the enum cannot name.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-07 12:45 UTC
Signed with PGP, not checked
Commit: 3aae8574bf70f4965f0399991bc730ad8aedbf3b
Parent: 38adf01
21 files changed, +976 insertions, -11 deletions
@@ -111,6 +111,17 @@
111 111 pub const SYNCKIT_WARNINGS_PER_TICK: i64 = 200;
112 112 pub const SYNCKIT_ROTATION_BATCH_MAX: usize = 500;
113 113
114 + /// How long an unacknowledged alert waits before it is sent again.
115 + pub const ACKNOWLEDGEMENT_REPEAT_DAYS: i64 = 7;
116 + /// Unanswered sends before the alert is handed to a person and sending stops.
117 + /// Four weekly messages is a month of being ignored, which is long enough to
118 + /// conclude that more of the same will not work.
119 + pub const ACKNOWLEDGEMENT_ESCALATE_AFTER: i32 = 4;
120 + /// Max due alerts one scheduler tick fetches. The sends fan out on the
121 + /// background pool and stamped rows drop out of the due set, so any overflow
122 + /// drains on the following ticks.
123 + pub const ACKNOWLEDGEMENTS_PER_TICK: i64 = 100;
124 +
114 125 // Subscriptions
115 126 pub const MIN_SUBSCRIPTION_PRICE_CENTS: i32 = 100; // $1.00 minimum
116 127
@@ -1257,6 +1257,34 @@
1257 1257 ];
1258 1258 }
1259 1259
1260 + /// What is waiting to be acknowledged.
1261 + ///
1262 + /// Closed and asserted against the `pending_acknowledgements.kind` CHECK
1263 + /// constraint by a test in `db::acknowledgements`: a variant added here without
1264 + /// a migration compiles, passes every unit test, and then fails at INSERT on a
1265 + /// deployed database.
1266 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1267 + pub enum AckKind {
1268 + /// The creator's Stripe account changed settlement currency, so every price
1269 + /// they have already set is now a number denominated in different money.
1270 + SettlementCurrencyChanged,
1271 + }
1272 +
1273 + impl_str_enum!(AckKind {
1274 + SettlementCurrencyChanged => "settlement_currency_changed",
1275 + });
1276 +
1277 + impl AckKind {
1278 + pub const ALL: &'static [AckKind] = &[AckKind::SettlementCurrencyChanged];
1279 +
1280 + /// Subject line and page heading, one source for both.
1281 + pub fn title(self) -> &'static str {
1282 + match self {
1283 + Self::SettlementCurrencyChanged => "Your prices are now in a different currency",
1284 + }
1285 + }
1286 + }
1287 +
1260 1288 /// Where a subscription stands.
1261 1289 ///
1262 1290 /// `Imported` is its own state on purpose. Everything the step-2 backfill
@@ -192,6 +192,7 @@
192 192 RevenueSplitId,
193 193 ModerationActionId,
194 194 MtThreadId,
195 + PendingAcknowledgementId,
195 196 ClaimToken,
196 197 DownloadToken,
197 198 );
@@ -4,6 +4,7 @@
4 4 //! Types (id_types, validated_types, enums, models) are re-exported flat.
5 5 //! Query functions live in their submodules: `db::users::get_user_by_id()`.
6 6
7 + pub mod acknowledgements; // pub so the integration test crate can drive the nag/escalate cycle directly
7 8 pub mod admin_alerts;
8 9 pub(crate) mod analytics;
9 10 pub(crate) mod auth;
@@ -541,6 +541,19 @@
541 541 Ok(row.map(|(c,)| c))
542 542 }
543 543
544 + /// The account behind a Stripe Connect account id.
545 + #[tracing::instrument(skip_all)]
546 + pub async fn get_user_id_by_stripe_account(
547 + pool: &PgPool,
548 + stripe_account_id: &str,
549 + ) -> Result<Option<UserId>> {
550 + let id = sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE stripe_account_id = $1")
551 + .bind(stripe_account_id)
552 + .fetch_optional(pool)
553 + .await?;
554 + Ok(id)
555 + }
556 +
544 557 /// Store a buyer's cross-currency conversion preference.
545 558 ///
546 559 /// A preference, not a lock: the checkout form decides each purchase, and this
@@ -80,6 +80,7 @@
80 80 ContentExport,
81 81 CreatorDeparture,
82 82 UsageLimit,
83 + AcknowledgementRequired,
83 84 /// Mail to an operator address (support routing, webhook failures, monitor
84 85 /// alerts). Not addressed to a user account at all, so there is no
85 86 /// preference to consult; it is named rather than left as an unclassified
@@ -112,6 +113,7 @@
112 113 OperationalKind::ContentExport,
113 114 OperationalKind::CreatorDeparture,
114 115 OperationalKind::UsageLimit,
116 + OperationalKind::AcknowledgementRequired,
115 117 OperationalKind::OperatorAlert,
116 118 ];
117 119
@@ -137,6 +139,7 @@
137 139 Self::ContentExport => "Content export",
138 140 Self::CreatorDeparture => "A creator you bought from is leaving",
139 141 Self::UsageLimit => "Usage limit warning",
142 + Self::AcknowledgementRequired => "Something needs your confirmation",
140 143 Self::OperatorAlert => "Operator alert",
141 144 }
142 145 }
@@ -222,6 +225,10 @@
222 225 "You are approaching or have reached a plan limit. Past it, requests are \
223 226 refused, so a silent limit would read as an outage."
224 227 }
228 + Self::AcknowledgementRequired => {
229 + "Something changed that only you can act on, and it repeats until you \
230 + confirm you have seen it. Confirming is what stops it."
231 + }
225 232 Self::OperatorAlert => {
226 233 "Sent to a Makenotwork operations address, not to a user account."
227 234 }
@@ -302,6 +309,7 @@
302 309 "Content export",
303 310 "A creator you bought from is leaving",
304 311 "Usage limit warning",
312 + "Something needs your confirmation",
305 313 "Operator alert",
306 314 ];
307 315 assert_eq!(
@@ -341,10 +349,11 @@
341 349 | OperationalKind::ContentExport
342 350 | OperationalKind::CreatorDeparture
343 351 | OperationalKind::UsageLimit
352 + | OperationalKind::AcknowledgementRequired
344 353 | OperationalKind::OperatorAlert => k.justification(),
345 354 };
346 355 }
347 - assert_eq!(OperationalKind::ALL.len(), 20);
356 + assert_eq!(OperationalKind::ALL.len(), 21);
348 357 }
349 358
350 359 /// The published guide page matches the enum.