Skip to content

Block pattern detection (design) โ€‹

Date: 2026-07-26 Issues: #144 (Safety: Block Functionality, the last unimplemented requirement), #728 (remove Block from the wave card) Status: design, pre-implementation Skills consulted: privacy-architecture (the axiom and the sealed-identity invariants below), scenario-matrix (activation axis), test-plan

1. The gap โ€‹

Everything else in #144 shipped: block CRUD, private block lists, bidirectional server-side invisibility across lanterns / frens / waves / messages, a blocked-users list with unblock, and the whole report + moderation-case + enforcement + appeals chain. The audit is recorded in a comment on #144.

One requirement was never built:

Implement pattern detection: flag users receiving 5+ blocks in 30 days

docs/features/safety/SAFETY_MECHANICS.md (lines 82, 147-148) specifies the ladder: 5+ blocks in 30 days flags for review, 10+ triggers an automatic temporary suspension.

Consequence today: blocks are the most honest safety signal the platform has (a private, costless action taken by someone who was actually there), and nothing reads them in aggregate. A person can be blocked by twenty different people in a week and no moderator ever learns. Every upstream piece (blocks with optional reasons) and every downstream piece (cases, enforcement, notices, appeals) exists. Only the wire between them is missing.

2. Privacy constraints (these drive the whole design) โ€‹

Block lists are deliberately private per user (firestore.rules: users/{uid}/blocks/{blockedUid}, owner-only). That privacy is itself a safety property: a blocked user must never learn they were blocked, or blocking becomes a confrontation trigger rather than a quiet exit.

So the aggregate has to answer "how many distinct people blocked this user recently" while never becoming a map of who blocked whom.

Applying the axiom (prefer losing data over leaking it):

ConstraintConsequence
Blocked user must not learn a block happenedThe aggregate is server-only. Not on users/{uid} (partly client-readable), not readable by the subject
Nobody should be able to read who blocked whomEvent records store no blocker identity, only a timestamp
Counting distinct blockers requires distinguishing themDoc ID is an HMAC of the blocker uid, so a re-block collides and cannot double-count, without a readable blocker field
Aggressive purge over retainEvents outside the window are deleted, not archived. The counter is derived, never a permanent dossier
Sealed identity (section 14.6)The signal is keyed by userId only. No phoneHash anywhere near it, no plaintext userId in evidence blobs

The HMAC is dedup, not anonymity (stated plainly) โ€‹

blockEventId = HMAC-SHA256(serverSecret, blockerUid) prevents double-counting a block/unblock/re-block cycle by the same person. It does not make the blocker unknowable to an actor holding both the secret and the user list: uids are enumerable, so such an actor could brute-force the mapping.

That is an accepted, documented limit rather than a claim of anonymity. It defends the property that matters (no client, and no casual read of the data, reveals who blocked) and it keeps the count honest. If stronger unlinkability is ever needed, the answer is a counter with no per-blocker records at all, which costs the dedup property. Not worth it now.

3. Design โ€‹

3.1 Storage โ€‹

moderationSignals/{blockedUserId}                     <- server-only, rules deny all client access
  blockCount30d: number        (derived, recomputed on each event)
  lastBlockAt: Timestamp
  thresholdsCrossed: { flag5?: Timestamp, suspend10?: Timestamp }
  events/{hmac(blockerUid)}
    at: Timestamp              <- the ONLY field. No blocker identity.

events is a subcollection so a single doc cannot grow unbounded and so create() gives idempotency for free.

3.2 Trigger โ€‹

Cloud Function onDocumentCreated('users/{blockerUid}/blocks/{blockedUid}'). This is a Firestore event trigger, the sanctioned Cloud Functions use under AGENTS.md rule 9, so it adds no HTTP surface.

  1. Write events/{hmac(blockerUid)} with create(). ALREADY_EXISTS (gRPC 6) means this blocker already counted inside the window: stop, no double count.
  2. Delete events older than 30 days (the window is enforced on read-and-write, so a stale event can never inflate a count).
  3. Recount surviving events, write blockCount30d.
  4. If the count crosses a threshold not already recorded in thresholdsCrossed, act (below) and stamp it. Stamping makes threshold actions single-fire: the 6th block does not re-open a case for the 5-block threshold.

3.3 Threshold actions โ€‹

Recommendation for v1: flag for review only. Do NOT auto-suspend at 10.

SAFETY_MECHANICS.md specifies automatic temporary suspension at 10+. I am deliberately not implementing that half yet, and the reason is not effort:

  • Brigading. Blocks are unverifiable by design (private, reasonless, no evidence). Ten coordinated accounts could suspend anyone. Every other enforcement path in this system requires either evidence or a human decision; auto-suspension on blocks alone would be the only path that needs neither.
  • The alpha's population makes it worse, not better. In a small venue-based pilot, ten blocks could plausibly be one social group, and there is no volume for a base rate to make 10 meaningful.
  • The ladder still works without it. A flagged case lands in the queue a moderator already reads, and they can suspend with one click through the existing enforcement path. The automation we lack is detection, not punishment.

So: 5+ distinct blocks in 30 days opens (or annotates) a moderation case at elevated priority. 10+ stamps a second, louder annotation and raises priority again, still for human action. Revisit auto-suspension when there is a real base rate to calibrate against. Recorded as decision D4.

3.4 Reaching the moderator queue (the part that would have shipped inert) โ€‹

Finding: the admin cases queue is driven by userReports, not by moderationCases. apps/admin/src/shared/lib/caseService.js subscribeToCases() iterates report groups and merges moderationCases in only as state (status, assignee, banned). A case with no reports never renders.

So a block-pattern flag that writes only a moderationCases doc would be invisible to every moderator: exactly the ship-it-inert failure this session already hit twice (the output style, the VAPID key).

Two options considered:

OptionHowVerdict
Synthesize a userReports docTrigger writes a system-authored report so the existing queue picks it upRejected. Pollutes a collection whose semantics are "a user reported this", and the reporter-facing "your reports" view keys off reporterId
Make the queue case-drivensubscribeToCases emits the union of report-derived cases and moderationCases docs, so a case can exist without reportsChosen. The queue should show any case, whatever opened it. Also unblocks every future non-report signal

The case doc therefore carries enough to render on its own: caseId/reportedUserId (the uid), source: 'block-pattern', signalSummary (count + window), status, lastActivityAt, and an activity entry describing the crossing. No blocker identities, no phone data.

3.5 What a moderator sees โ€‹

"This account was blocked by 6 different people in the last 30 days." Plus the block reasons if any were given, aggregated and stripped of who said them. Never a list of blockers.

4. #728: remove Block from the wave card โ€‹

Folded in here because both reshape the same surface and should be designed once.

The wave card asks the recipient to block someone the UI renders as "Anonymous Lantern", which is a decision with zero information, and it bypasses the real BlockReportFlow (bare confirm(), no reason captured, no report offer).

The gap removal leaves: server-side wave dedupe only checks for an existing pending wave (services/api/lanterns/src/services/wave.service.js:65), so a declined sender can immediately wave again, repeatedly. The wave-card block was quietly covering that.

Fix: decline implies suppression. A declined wave suppresses further waves from that sender to that recipient, enforced server-side where the dedupe already lives. One tap, no judgment call about a stranger, and no permanent record of a private action.

Suppression lasts until the recipient's lantern ends (operator decision, 2026-07-26). Not a fixed clock. It ties the remedy to the thing the user actually cares about, the evening they are currently in: a declined sender cannot keep pestering them at this venue tonight, and the slate clears when the lantern dies. Nothing durable is stored, which also means no accidental shadow-block that outlives the situation. Both parties lighting again later is a fresh start.

5. Decision log โ€‹

#DecisionRationale
D1Aggregate lives in a server-only moderationSignals collection, not on the user docThe user doc is partly client-readable; the subject of the signal must never be able to read it
D2Event records store a timestamp and nothing elseStoring blocker identity would build the who-blocked-whom map that block privacy exists to prevent
D3Doc ID is HMAC(serverSecret, blockerUid) for dedup, documented as NOT anonymity against an actor holding the secret plus the uid listKeeps counts honest across block/unblock/re-block without a readable blocker field; uids are enumerable so no stronger claim is made
D4v1 flags for human review only; the spec'd auto-suspension at 10+ is deliberately deferredBlocks are unverifiable by design, so auto-suspension would be the only enforcement path needing neither evidence nor a human. Brigading would weaponize it, and a small pilot has no base rate to calibrate against. The missing automation is detection, not punishment
D5Threshold crossings are stamped and single-fireOtherwise every subsequent block re-opens the same case
D6Events outside the 30-day window are deleted on each writeRetention hygiene per the purge principle, and it makes the count self-correcting rather than monotonic
D7The admin queue becomes case-driven (union) instead of report-drivenA block-pattern case has no reports and would otherwise be invisible. Fixes the class, not just this signal
D8Rejected synthesizing a userReports doc to piggyback on the existing queueFalsifies the meaning of a user report, and the reporter-facing view keys off reporterId
D9Block reasons surfaced only in aggregate, stripped of attributionA moderator needs the pattern, not the identities
D10#728 handled as decline-implies-suppression rather than only deleting the buttonDeleting it alone would leave repeat unwanted waves with no remedy, since dedupe checks only pending waves
D11Suppression also triggers on a DISMISSED wave (the card's X), not only a declined oneThe X is the gesture that reads as "leave me alone" while telling the sender nothing, and it bought no protection at all: it writes only dismissedAt, leaves status pending, and the pending dedupe stops matching after the one-hour expiry. So the softest no left the recipient re-waveable hourly, forever, which is precisely the hole the removed Block link had been covering.
D12PR #730 merges as-is; the sender-side disclosure (#731) becomes a separate follow-up branch, not part of #730Rule 12 defaults to one PR, and this is the dependency exception it carves out: #731 needs a product decision, not just code, so bundling it would park a finished and thrice-reviewed PR behind an open question while every push reran CI on the whole thing (billed overage). Its fix also changes firestore.rules read permissions and the sender's wave UI, a blast radius the existing review rounds did not cover. Sequential, so still one PR open at a time. (Executive decision, operator delegated 2026-07-27.)
D13For #731, take the "collapse declined into expired from the sender's perspective" optionMakes a decline as invisible to the sender as a block already is: indistinguishable from absence, which is the model the rest of the safety surface already uses, so it needs no new UI vocabulary and no new status. The alternatives are worse per unit of change: stopping all terminal-status exposure forces the sender UI onto a "not actionable" concept it does not have today; accept-and-document leaves the privacy claim weaker than the block model it sits beside; and hiding the Wave button alone would disclose MORE (a missing button is itself a signal). Chosen over "accept and document" deliberately: a quiet no that the sender can look up is not a quiet no. (Executive decision, operator delegated 2026-07-27.)
D14#731 implementation shape: decline and dismiss leave ZERO trace on the wave doc. The markers move to a recipient-private store (users/{uid}/waveSuppressions/{waveId}, owner-only rules), and a suppressed re-wave is created normally but filtered out of every recipient surface (inbox, popup, push) instead of being refused with a 409Two residual channels would otherwise survive D13. (1) Firestore read rules are document-level, so ANY decline field on the wave doc (status or a sibling like dismissedAt) is readable by the sender; the marker must live where the sender has no read path at all. (2) The matched 409 itself becomes an oracle after natural expiry: a re-wave that succeeds means "was ignored", a 409 means "was declined or dismissed", so refusing the wave discloses exactly what D13 hides. Creating the wave for real and filtering it recipient-side makes the sender's whole timeline (pending, expires in an hour, can wave again) literally identical to being ignored, which is the block model's bar: indistinguishable from absence. Costs: recipient-side filtering joins two queries, the wave-created push must consult the marker store, and up to one invisible wave doc per hour per suppressed sender exists until the 7-day purge. The recipient's decline experience is unchanged. (Executive decision, session of 2026-07-27.)

5b. Deployment prerequisite (done, but do not lose this) โ€‹

BLOCK_SIGNAL_HMAC_KEY must exist in Secret Manager or the trigger runs in its degraded fallback: random event ids, so a block / unblock / re-block by the same person can count more than once. It still never writes a reversible blocker identifier, which is the property that must not degrade.

Created on lantern-app-dev 2026-07-26 (version 1, 32 random bytes, automatic replication). The Cloud Functions runtime service account is granted access by firebase deploy when a function declares the secret in its secrets array, which onBlockCreatedSignal does.

This is exactly the class of thing the testing skills now require an ACTIVATION check for: the feature would have deployed, logged cleanly, and quietly counted wrong. To verify after a deploy, confirm the function's logs do NOT contain BLOCK_SIGNAL_HMAC_KEY unset.

6. Open questions for the operator โ€‹

  1. Cooldown length for decline-implies-suppression RESOLVED 2026-07-26: suppression lasts until the lantern dies, then re-waving is allowed again. No fixed clock, nothing durable stored. See section 4.
  2. Should a flagged case notify anyone in real time, or just sit in the queue? Proposal: queue only for now, since there is no on-call rotation.
  3. Sender-side disclosure of a decline BUILT (D13 shape, D14 mechanics): declines/dismissals live in the recipient-private users/{uid}/waveSuppressions store, the wave doc carries no decline data (rules-enforced), and suppressed re-waves are created-and-filtered rather than 409'd. Closes #731.

Built with VitePress