···376376 h.SetModeration(moderationSvc, moderationStore)
377377 }
378378379379+ // Periodic cleanup of expired moderation labels
380380+ go func() {
381381+ ticker := time.NewTicker(1 * time.Hour)
382382+ defer ticker.Stop()
383383+ for {
384384+ select {
385385+ case <-ticker.C:
386386+ if n, err := moderationStore.CleanExpiredLabels(ctx); err != nil {
387387+ log.Error().Err(err).Msg("Failed to clean expired labels")
388388+ } else if n > 0 {
389389+ log.Info().Int("count", n).Msg("Cleaned expired moderation labels")
390390+ }
391391+ case <-ctx.Done():
392392+ return
393393+ }
394394+ }
395395+ }()
396396+379397 // Initialize join request handling
380398 smtpPort := 587
381399 if portStr := os.Getenv("SMTP_PORT"); portStr != "" {
+145-1
docs/moderation-improvements.md
···302302DELETE FROM moderation_labels WHERE expires_at IS NOT NULL AND expires_at < ?
303303```
304304305305+### Relationship to AT Protocol Labels
306306+307307+AT Protocol has its own label system (`com.atproto.label`) designed for
308308+**federated, decentralized moderation**. It's worth understanding how it differs
309309+from the internal labels proposed above, whether Arabica should use both, and
310310+when each applies.
311311+312312+#### How atproto labels work
313313+314314+atproto labels are cryptographically signed metadata tags produced by independent
315315+**labeler services** — third-party identities (with their own DID and signing
316316+key) that publish labels about accounts or records across the network. Key
317317+properties:
318318+319319+- **Signed**: Each label is CBOR-encoded and signed with the labeler's
320320+ `#atproto_label` key. Clients verify authenticity without trusting the
321321+ transport.
322322+- **User-choosable**: Users subscribe to labelers they trust (up to 20). Each
323323+ user independently decides how to handle each label value: hide, warn, or
324324+ ignore. This is fundamentally different from moderator-imposed decisions.
325325+- **Distributed**: Labels are broadcast via
326326+ `com.atproto.label.subscribeLabels` (WebSocket stream) and queryable via
327327+ `com.atproto.label.queryLabels`. AppViews hydrate labels into API responses
328328+ based on the client's `atproto-accept-labelers` header.
329329+- **Graduated actions**: Label values define `severity` (inform/alert/none) and
330330+ `blurs` (content/media/none). The `!hide` and `!warn` system labels are
331331+ non-overridable; content labels like `porn`, `graphic-media` let users choose.
332332+- **Negatable and expirable**: A label is retracted by publishing a negation
333333+ (`neg: true`) with the same src/uri/val. Labels can also carry `exp` for
334334+ automatic expiry.
335335+- **Self-labels**: Record authors can self-apply global label values (e.g.,
336336+ `porn`, `nudity`, `graphic-media`) directly in their records via
337337+ `com.atproto.label.defs#selfLabels`.
338338+339339+The indigo SDK already provides the Go types (`LabelDefs_Label`,
340340+`LabelDefs_SelfLabels`) and signing/verification utilities.
341341+342342+#### How internal labels differ
343343+344344+The labels proposed in this doc are **app-internal operational state** — they
345345+exist only inside Arabica's SQLite database, are not signed or distributed, and
346346+are invisible to the broader AT Protocol network. They serve a fundamentally
347347+different purpose:
348348+349349+| Aspect | Internal Labels | AT Protocol Labels |
350350+|--------|----------------|--------------------|
351351+| **Purpose** | Automod state, moderator notes | Network-wide content classification |
352352+| **Audience** | Arabica's automod engine + moderators | Any atproto client, user-selectable |
353353+| **Storage** | SQLite row | Signed CBOR object, distributed via WebSocket |
354354+| **Who creates** | Automod rules or Arabica moderators | Independent labeler services |
355355+| **Who consumes** | Arabica's rule evaluator | End users via their chosen client |
356356+| **Visibility** | Admin dashboard only | Public (any subscriber can see) |
357357+| **User choice** | None — moderator decisions are authoritative | Users choose labelers and per-label behavior |
358358+| **Examples** | `warned`, `trusted`, `under_review`, `rate_limited` | `porn`, `graphic-media`, `!hide`, `spam` |
359359+360360+These are complementary, not competing. Internal labels are private moderator
361361+bookkeeping. atproto labels are public content classification signals.
362362+363363+#### Should Arabica use both?
364364+365365+**Yes** — but for different things, and not at the same time.
366366+367367+**Phase 1: Internal labels (this proposal)**
368368+369369+Internal labels are the right tool for what's described in this doc: giving
370370+automod memory and letting moderators annotate users. A `warned` label that
371371+expires in 30 days, or a `trusted` label that exempts someone from automod —
372372+these are operational concerns that don't belong on the public network. They're
373373+the equivalent of internal moderation notes, not public content ratings.
374374+375375+Ship internal labels first. They have zero infrastructure requirements, integrate
376376+directly with the automod rule evaluator, and solve the immediate problem of
377377+stateless automod.
378378+379379+**Phase 2: Arabica as an atproto labeler (future)**
380380+381381+Later, Arabica could operate as a labeler service — publishing labels that other
382382+atproto clients can subscribe to. This would be valuable for:
383383+384384+- **Content warnings on brew records**: Labeling brews that contain
385385+ controversial ingredients or methods (e.g., if the community ever needs
386386+ content classification beyond coffee)
387387+- **Quality signals**: A `verified-roaster` or `featured` label that other
388388+ clients could consume to highlight trusted content
389389+- **Spam classification**: Publishing `spam` labels on records Arabica's automod
390390+ has flagged, so other apps indexing `social.arabica.alpha.*` collections can
391391+ benefit from Arabica's moderation work
392392+- **Cross-app moderation**: If other apps build on the arabica lexicons, they
393393+ could subscribe to Arabica's labeler to get moderation decisions without
394394+ running their own moderation
395395+396396+This would require:
397397+1. A signing key (`#atproto_label`) in Arabica's DID document
398398+2. An `app.bsky.labeler.service` declaration record
399399+3. `subscribeLabels` and `queryLabels` endpoints
400400+4. A mapping from internal moderation actions → published atproto labels
401401+402402+#### When an action should become a public label
403403+404404+Not every internal label should be published externally. A rough heuristic:
405405+406406+| Internal action | Publish as atproto label? | Why / why not |
407407+|-----------------|--------------------------|---------------|
408408+| `hide_record` | Yes → `!hide` | Other apps indexing arabica records should respect this |
409409+| `blacklist_user` | Maybe → custom `blocked` | Depends on whether other apps need to know |
410410+| `warned` | No | Private moderator state, not relevant to other clients |
411411+| `trusted` | No | Internal automod bypass, meaningless externally |
412412+| `spam` | Yes → `spam` | Useful for any app consuming arabica records |
413413+| `under_review` | No | Temporary internal state |
414414+415415+The bridge between the two systems would be straightforward: when an internal
416416+moderation action fires that warrants a public label, also sign and publish an
417417+atproto label. The internal label drives automod behavior; the atproto label
418418+communicates the decision to the network.
419419+420420+#### What this means for the current proposal
421421+422422+No changes needed to the internal labels design. The schema, store interface, and
423423+automod integration all remain as specified above. The only future consideration
424424+is adding an optional `publish_label` action type to the automod rules config
425425+(Phase 3), which would sign and emit an atproto label alongside the internal
426426+action:
427427+428428+```json
429429+{
430430+ "name": "publish_spam_label",
431431+ "trigger": "record_auto_hidden",
432432+ "conditions": {
433433+ "has_label": { "entity": "subject_record", "label": "spam" }
434434+ },
435435+ "action": { "type": "publish_label", "val": "spam" }
436436+}
437437+```
438438+439439+This is purely additive and doesn't affect the Phase 2/3 work described in this
440440+doc.
441441+305442---
306443307444## 3. Permission Middleware
···477614- **No investigation UI** — the existing admin dashboard gets labels, not a
478615 query engine
479616- **No ML/AI integration** — rules are deterministic threshold checks
617617+- **No atproto labeler service** — internal labels are private operational
618618+ state, not published to the network. Becoming an atproto labeler is a natural
619619+ future extension (see "Relationship to AT Protocol Labels" in section 2) but
620620+ is out of scope for this work.
480621481622These are deliberate constraints. If the moderation needs outgrow this, that's
482623the point where Osprey (or a similar system) becomes worth the infrastructure
483483-cost.
624624+cost. The atproto labeler path is a lighter lift than Osprey — it reuses the
625625+existing moderation decisions and just adds a signed publication layer — so it's
626626+a reasonable intermediate step if cross-app moderation becomes needed before
627627+full-scale infrastructure is justified.
+126
internal/database/sqlitestore/moderation.go
···414414 t, _ := time.Parse(time.RFC3339Nano, resetAtStr)
415415 return t, nil
416416}
417417+418418+// ========== Labels ==========
419419+420420+func (s *ModerationStore) AddLabel(ctx context.Context, label moderation.Label) error {
421421+ var expiresAt *string
422422+ if label.ExpiresAt != nil {
423423+ v := label.ExpiresAt.Format(time.RFC3339Nano)
424424+ expiresAt = &v
425425+ }
426426+ _, err := s.db.ExecContext(ctx, `
427427+ INSERT INTO moderation_labels (id, entity_type, entity_id, label, value, created_at, created_by, expires_at)
428428+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
429429+ ON CONFLICT(entity_type, entity_id, label) DO UPDATE SET
430430+ id = excluded.id,
431431+ value = excluded.value,
432432+ created_at = excluded.created_at,
433433+ created_by = excluded.created_by,
434434+ expires_at = excluded.expires_at
435435+ `, label.ID, label.EntityType, label.EntityID, label.Name, label.Value,
436436+ label.CreatedAt.Format(time.RFC3339Nano), label.CreatedBy, expiresAt)
437437+ if err != nil {
438438+ return fmt.Errorf("add label: %w", err)
439439+ }
440440+ return nil
441441+}
442442+443443+func (s *ModerationStore) RemoveLabel(ctx context.Context, entityType, entityID, labelName string) error {
444444+ _, err := s.db.ExecContext(ctx, `
445445+ DELETE FROM moderation_labels WHERE entity_type = ? AND entity_id = ? AND label = ?
446446+ `, entityType, entityID, labelName)
447447+ return err
448448+}
449449+450450+func (s *ModerationStore) HasLabel(ctx context.Context, entityType, entityID, labelName string) (bool, error) {
451451+ var exists int
452452+ err := s.db.QueryRowContext(ctx, `
453453+ SELECT 1 FROM moderation_labels
454454+ WHERE entity_type = ? AND entity_id = ? AND label = ?
455455+ AND (expires_at IS NULL OR expires_at > ?)
456456+ `, entityType, entityID, labelName, time.Now().Format(time.RFC3339Nano)).Scan(&exists)
457457+ if err == sql.ErrNoRows {
458458+ return false, nil
459459+ }
460460+ return exists == 1, err
461461+}
462462+463463+func (s *ModerationStore) GetLabel(ctx context.Context, entityType, entityID, labelName string) (*moderation.Label, error) {
464464+ var l moderation.Label
465465+ var createdAtStr string
466466+ var expiresAtStr sql.NullString
467467+ err := s.db.QueryRowContext(ctx, `
468468+ SELECT id, entity_type, entity_id, label, value, created_at, created_by, expires_at
469469+ FROM moderation_labels WHERE entity_type = ? AND entity_id = ? AND label = ?
470470+ `, entityType, entityID, labelName).Scan(
471471+ &l.ID, &l.EntityType, &l.EntityID, &l.Name, &l.Value,
472472+ &createdAtStr, &l.CreatedBy, &expiresAtStr)
473473+ if err == sql.ErrNoRows {
474474+ return nil, nil
475475+ }
476476+ if err != nil {
477477+ return nil, err
478478+ }
479479+ l.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr)
480480+ if expiresAtStr.Valid {
481481+ t, _ := time.Parse(time.RFC3339Nano, expiresAtStr.String)
482482+ l.ExpiresAt = &t
483483+ }
484484+ return &l, nil
485485+}
486486+487487+func (s *ModerationStore) ListLabels(ctx context.Context, entityType, entityID string) ([]moderation.Label, error) {
488488+ rows, err := s.db.QueryContext(ctx, `
489489+ SELECT id, entity_type, entity_id, label, value, created_at, created_by, expires_at
490490+ FROM moderation_labels WHERE entity_type = ? AND entity_id = ?
491491+ ORDER BY created_at DESC
492492+ `, entityType, entityID)
493493+ if err != nil {
494494+ return nil, err
495495+ }
496496+ defer rows.Close()
497497+ return scanLabels(rows)
498498+}
499499+500500+func (s *ModerationStore) ListAllLabels(ctx context.Context) ([]moderation.Label, error) {
501501+ rows, err := s.db.QueryContext(ctx, `
502502+ SELECT id, entity_type, entity_id, label, value, created_at, created_by, expires_at
503503+ FROM moderation_labels ORDER BY created_at DESC
504504+ `)
505505+ if err != nil {
506506+ return nil, err
507507+ }
508508+ defer rows.Close()
509509+ return scanLabels(rows)
510510+}
511511+512512+func scanLabels(rows *sql.Rows) ([]moderation.Label, error) {
513513+ var labels []moderation.Label
514514+ for rows.Next() {
515515+ var l moderation.Label
516516+ var createdAtStr string
517517+ var expiresAtStr sql.NullString
518518+ if err := rows.Scan(
519519+ &l.ID, &l.EntityType, &l.EntityID, &l.Name, &l.Value,
520520+ &createdAtStr, &l.CreatedBy, &expiresAtStr); err != nil {
521521+ continue
522522+ }
523523+ l.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAtStr)
524524+ if expiresAtStr.Valid {
525525+ t, _ := time.Parse(time.RFC3339Nano, expiresAtStr.String)
526526+ l.ExpiresAt = &t
527527+ }
528528+ labels = append(labels, l)
529529+ }
530530+ return labels, rows.Err()
531531+}
532532+533533+func (s *ModerationStore) CleanExpiredLabels(ctx context.Context) (int, error) {
534534+ res, err := s.db.ExecContext(ctx, `
535535+ DELETE FROM moderation_labels WHERE expires_at IS NOT NULL AND expires_at < ?
536536+ `, time.Now().Format(time.RFC3339Nano))
537537+ if err != nil {
538538+ return 0, err
539539+ }
540540+ n, _ := res.RowsAffected()
541541+ return int(n), nil
542542+}
···212212 reset_at TEXT NOT NULL
213213);
214214215215+CREATE TABLE IF NOT EXISTS moderation_labels (
216216+ id TEXT PRIMARY KEY,
217217+ entity_type TEXT NOT NULL,
218218+ entity_id TEXT NOT NULL,
219219+ label TEXT NOT NULL,
220220+ value TEXT NOT NULL DEFAULT '',
221221+ created_at TEXT NOT NULL,
222222+ created_by TEXT NOT NULL,
223223+ expires_at TEXT,
224224+ UNIQUE(entity_type, entity_id, label)
225225+);
226226+CREATE INDEX IF NOT EXISTS idx_modlabels_entity ON moderation_labels(entity_type, entity_id);
227227+CREATE INDEX IF NOT EXISTS idx_modlabels_expires ON moderation_labels(expires_at) WHERE expires_at IS NOT NULL;
228228+215229CREATE TABLE IF NOT EXISTS user_settings (
216230 did TEXT PRIMARY KEY,
217231 profile_stats_visibility TEXT NOT NULL DEFAULT '{}'