[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail
contrail docs 09-labels.md
6.5 kB

Labels #

Atproto-native moderation hydration. Subscribe to one or more labelers, index their labels, and attach them to records and profiles in your XRPC responses. Opt-in; zero cost if you don't enable it.

Mental model #

A label is a (src, uri, val) triple authored by a labeler DID. A labeler is a regular atproto account that publishes signed annotations about other accounts and records via com.atproto.label.subscribeLabels.

  • One contrail deployment can subscribe to many labelers.
  • The caller of your XRPC picks which subset to honor per request via the atproto-accept-labelers header (or ?labelers= query param when headers are awkward — SSE/WS).
  • Labels hydrate onto every listRecords, getRecord, getProfile, and ?profiles=true response without changing your collection config.
  • This module only consumes labels. Producing them — your appview emitting its own labels — is a separate question. See Future work below.

Enable #

import type { ContrailConfig } from "@atmo-dev/contrail";

const config: ContrailConfig = {
  namespace: "com.example",
  collections: { /* ... */ },
  labels: {
    sources: [
      { did: "did:plc:ar7c4by46qjdydhdevvrndac" }, // bsky moderation
      { did: "did:plc:newsmast" },
    ],
  },
};

initSchema creates a labels table and a labeler_cursors table. Both live on the main DB; nothing per-collection.

Caller selection #

Per request, contrail picks accepted labelers in this order:

  1. atproto-accept-labelers: did:plc:a, did:plc:b — the spec's HTTP header.
  2. ?labelers=did:plc:a,did:plc:b — fallback for transports that can't set headers easily.
  3. config.labels.defaults — operator policy.
  4. Every entry in config.labels.sources.

The list is intersected with what's actually configured (unknowns dropped — only labelers we've subscribed to have rows to hydrate from) and capped at maxPerRequest (default 20). Contrail echoes the applied set back via atproto-content-labelers.

GET /xrpc/com.example.event.listRecords
  atproto-accept-labelers: did:plc:ar7c4by46qjdydhdevvrndac

→

// Response: atproto-content-labelers: did:plc:ar7c4by46qjdydhdevvrndac
{
  "records": [
    {
      "uri": "at://did:plc:.../com.example.event/...",
      "value": { /* ... */ },
      "labels": [
        {
          "src": "did:plc:ar7c4by46qjdydhdevvrndac",
          "uri": "at://did:plc:.../com.example.event/...",
          "val": "spam",
          "cts": "2026-04-25T00:00:00.000Z"
        }
      ]
    }
  ]
}

labels matches com.atproto.label.defs#label field-for-field — pass it straight to atproto SDK moderation helpers.

defaults: [] #

Set defaults to an empty array if you want strict opt-in: callers that send no header / param see no labels at all.

Hydration semantics #

For each (src, uri, val) tuple visible to the caller, hydration picks the row with the highest cts. If that row has neg=true, the label is treated as retracted and dropped. Expired rows (exp past now) are filtered at the SQL level. CID-pinned labels apply only when the indexed record's CID matches.

Account-level labels (subject = bare DID) hydrate onto profiles. They appear inside each ProfileEntry.labels of the profiles array on ?profiles=true responses, and on getProfile.

Ingestion #

com.atproto.label.subscribeLabels is a per-labeler WebSocket firehose with a CBOR frame envelope. Contrail mirrors its existing Jetstream pipeline:

Mode Function When
Cron-driven contrail.ingestLabels() Cloudflare Workers — one drain per cron tick
Persistent contrail.runPersistentLabels() Node / long-lived servers — one socket per labeler, auto-reconnect
One-shot backfill pnpm contrail backfill --only labels [--remote] Local script, drains until each labeler reports caught up. (pnpm contrail backfill runs both records and labels.)

When config.labels is set, the bundled createWorker already calls ingestLabels() from scheduled() alongside ingest() — no boilerplate.

// node / long-lived
const ac = new AbortController();
await Promise.all([
  contrail.runPersistent({ signal: ac.signal }),
  contrail.runPersistentLabels({ signal: ac.signal }),
]);

Per-labeler cursors live in labeler_cursors ({did, cursor, endpoint, resolved_at}). Endpoints are resolved from the DID doc's service[id="#atproto_labeler"] and cached for 6h. On #info { name: "OutdatedCursor" } frames, contrail resets the cursor to 0 so the next cycle re-backfills.

backfill: false #

Per source. Default: backfill from cursor=0 on first sight. Set false to start at "now" — useful for very chatty labelers where you don't need history.

labels: {
  sources: [{ did: "did:plc:somenoisylabeler", backfill: false }],
}

Storage #

CREATE TABLE labels (
  src TEXT NOT NULL,        -- labeler DID
  uri TEXT NOT NULL,        -- subject: at://... or did:...
  val TEXT NOT NULL,        -- label value
  cid TEXT,                 -- optional record-version pin
  neg INTEGER NOT NULL DEFAULT 0,
  exp INTEGER,              -- expiry, unix sec
  cts INTEGER NOT NULL,     -- creation time, unix sec
  sig BLOB,                 -- signature bytes (stored, not verified in v1)
  PRIMARY KEY (src, uri, val, cts)
);

The PK includes cts, so a neg=true retraction is a new row that replaces the previous decision via the read-time collapse rule above — never an in-place mutation. This matches the spec, tolerates out-of-order delivery, and survives a labeler that flip-flops.

What's not here #

  • Signature verification. sig is stored if the labeler supplies it, but contrail does not verify it in v1. Document as TODO; most appviews skip it.
  • Outbound subscribeLabels. Contrail consumes labels but does not act as a labeler or republish them.
  • Label definitions / preferences UX. Custom label names, blur behaviors, severity, and per-user preference state belong on the client, fetched directly from each labeler. Contrail intentionally stays out of this.

Design #

Follows the atproto label spec literally. The wire format on responses matches com.atproto.label.defs#label so existing atproto SDKs can consume it directly. Storage is the data model normalized into rows; ingestion mirrors Jetstream both in code shape and in operator UX.