cairn-mod 1.3.0

Lightweight, Rust-native ATProto labeler
Documentation

cairn-mod

A lightweight, Rust-native ATProto labeler — single binary, SQLite-backed, designed for small and mid-scale community moderation.

CI Crates.io docs.rs License: MIT OR Apache-2.0 MSRV: 1.88 Contributor Covenant

Latest stable release: v1.3.0 · install with cargo install cairn-mod

The main branch contains active development toward the next release. For production deployments, pin to a released version.

What is cairn-mod?

cairn-mod is a standalone ATProto labeler server. It publishes a app.bsky.labeler.service record, signs labels per the ATProto spec, accepts user reports, and exposes an admin XRPC surface for moderators to act on them. It exists because the ecosystem has Ozone (heavy, TypeScript, Postgres-backed, opinionated web UI) and Skyware's labeler library (minimal, no report intake, no audit trail), with a gap between them for operators who want something compact but production-grade. cairn-mod is deliberately smaller than Ozone and deliberately more complete than Skyware; it does not try to be either.

Status

v1.3.0 is the current stable release. Install with cargo install cairn-mod or pin to the v1.3.0 tag. v1.3's "audit integrity" theme adds the hash-chained audit log: every audit_log row now carries prev_hash and row_hash columns (SHA-256 over DAG-CBOR canonical encoding), and operators can verify chain integrity via cairn audit verify, backfill pre-v1.3 rows via cairn audit-rebuild, and inspect individual hashes via the extended cairn audit show output. v1.3 also reconciles the design doc against four releases of drift (§11/§14/§16.1/§18/§19). Items deferred to v1.4 and beyond are tracked in the issue tracker.

v1.4 is in active development on main. Roadmap items in the design doc's §18 include signing-key rotation, label-expiry enforcement, and the observability surface (Prometheus /metrics, structured-log conventions).

Production deployments should pin to the stable release, not the main branch.

Quickstart

Target: a running cairn serve behind a reverse proxy, published service record, responding to GET /.well-known/did.json.

Prerequisites

  • Rust 1.88+ — install via rustup.
  • A DID for the labeler — either did:web:your.host or did:plc:.... How to obtain one is out of scope; the design doc §5.1 has the context.
  • A PDS account for the labeler — the labeler DID must have a live repo on a PDS (self-hosted or bsky.social). Publishing the service record writes to that repo.
  • A TLS-terminating reverse proxy — nginx or Caddy, see contrib/ for templates.

1. Install

cargo install cairn-mod

Produces a binary named cairn. On cargo's default install prefix, that ends up at ~/.cargo/bin/cairn; operators typically copy it to /usr/local/bin/cairn for systemd deployment (see contrib/README.md).

2. Generate a signing key

cairn-mod expects a 64-hex-char file containing a secp256k1 private key. Either of these produces one:

# Python (simplest):
python3 -c 'import secrets; print(secrets.token_hex(32))' > signing-key.hex

# Or openssl:
openssl ecparam -name secp256k1 -genkey -noout \
  | openssl ec -text -noout 2>/dev/null \
  | awk '/priv:/{flag=1;next} /pub:/{flag=0} flag' \
  | tr -d ': \n' > signing-key.hex

Then lock down permissions — §5.1 requires mode 0600 owned by the running user, and cairn serve refuses to start otherwise:

chmod 600 signing-key.hex
sudo chown cairn:cairn signing-key.hex       # if running under systemd
sudo mv signing-key.hex /var/lib/cairn/signing-key.hex

Publish the matching public key in the labeler's DID document at verification method #atproto_label. Consumers verifying cairn-mod's labels resolve the DID and extract this key.

3. Configure

Minimal cairn.toml:

# Labeler identity (§5.1). The DID must resolve to a document
# containing your signing key at verification method #atproto_label.
service_did      = "did:web:labeler.example"
# Public HTTPS URL consumers use to reach this labeler. Emitted in
# /.well-known/did.json as the AtprotoLabeler serviceEndpoint.
service_endpoint = "https://labeler.example"
# SQLite file. Created on first run; parent dir must exist.
db_path          = "/var/lib/cairn/cairn.db"
# Hex-encoded 32-byte secp256k1 key. Must be mode 0600 owned by
# the running user — cairn-mod refuses to start otherwise.
signing_key_path = "/var/lib/cairn/signing-key.hex"
# bind_addr defaults to "127.0.0.1:3000"; override if running
# without a reverse proxy on the same host.

# The app.bsky.labeler.service record `cairn publish-service-record`
# emits to your PDS. Lists what labels you declare and how consumers
# should render them.
[labeler]
label_values = ["spam"]

[[labeler.label_value_definitions]]
identifier      = "spam"
severity        = "alert"            # inform | alert | none
blurs           = "none"             # content | media | none
default_setting = "warn"             # ignore | warn | hide
locales         = [
  { lang = "en", name = "Spam", description = "Unsolicited promotional content." },
]

# Where the operator (= the labeler DID) authenticates to publish the
# service record. Separate from moderators authenticating to cairn-mod
# (§5.3) — different identity, different file.
[operator]
pds_url      = "https://bsky.social"
session_path = "/var/lib/cairn/operator-session.json"

See contrib/ for the systemd + Caddy + nginx templates that plug into these paths.

4. Bootstrap (one-time per deployment)

Authenticate to the operator's PDS and publish the service record:

cairn operator-login --handle labeler.example.com --config /etc/cairn/cairn.toml
cairn publish-service-record --config /etc/cairn/cairn.toml

The publish step is idempotent — re-running with unchanged config is a no-op.

To remove the published record (e.g., decommissioning a test deployment), run the inverse:

cairn unpublish-service-record --config /etc/cairn/cairn.toml

Idempotent — running it when nothing is published is also a no-op. The next cairn serve after an unpublish will fail-start with exit 13 SERVICE_RECORD_ABSENT until you republish.

5. Run

# Foreground:
cairn serve --config /etc/cairn/cairn.toml

# Or via systemd (contrib/):
sudo systemctl enable --now cairn

6. Verify

curl -sSL https://labeler.example/.well-known/did.json | jq '.verificationMethod[].id'

Should return "did:web:labeler.example#atproto_label" (or the suffixed forms during v1.1 key rotation).

Production Checklist

Walk through this before pointing real subscribers at the instance. Each item links the relevant design-doc section for deeper context.

Transport (§F13)

  • TLS terminates at the reverse proxy, not cairn-mod. cairn serve binds HTTP only (default 127.0.0.1:3000) and assumes a TLS-terminating proxy fronts it. See contrib/ for Caddy and nginx templates.
  • HSTS header on responses (Strict-Transport-Security: max-age=31536000; includeSubDomains). Set at the proxy; the contrib templates ship it.

Rate limits (§F13 numbers, enforced at reverse proxy)

  • createReport per-IP: example rate-limit configurations ship as commented operator-add stanzas in contrib/nginx/cairn.conf and contrib/caddy/Caddyfile. Operators uncomment and tune rate values based on expected traffic and abuse posture. The §F13 reference values (burst 3, rate 10/hour) are the design-doc baseline; the contrib examples mirror those numbers but ship commented by default to avoid version-specific syntax in shipped templates (the r/h rate unit landed in nginx 1.27, post-Ubuntu-LTS).
  • subscribeLabels per-IP: 8 concurrent connections. Enforced active-by-default in contrib/nginx/cairn.conf — connection caps (limit_conn_*) are version-stable across nginx 1.18+, so the conn-cap stanza ships uncommented unlike its rate-limit sibling. The Caddy template ships without (Caddy core lacks connection-cap primitives; caddy-ratelimit provides them but is a third-party module).

Secrets (§5.1, §5.3)

  • Signing key file at mode 0600, owned by the running user. cairn-mod's credential_file::check_mode_and_owner refuses wider permissions or foreign ownership at startup.
  • Signing key file NEVER committed to version control. A signing key in git history is a compromise even after deletion.
  • Signing key material NOT delivered via env var. The CAIRN_SIGNING_KEY env var is explicitly rejected by the loader (SIGNING_KEY_ENV_REJECTED constant) — the guardrail exists to prevent an "ergonomics" PR later adding a parallel unsafe path.
  • Operator session file (written by cairn operator-login) at 0600 owned by the running user. Treat it as equivalent to your PDS app password — anyone with read access can push records to the labeler's PDS repo until the session expires.

Key lifecycle (§4.1.6, §12)

  • Signing key is permanent for v1. Rotation is v1.1 scope. Plan the host, permissions, and backup accordingly — a v1 key lives as long as the labeler identity.
  • Do NOT remove the labeler signing key from the DID document. Every historical label cairn-mod has signed stops verifying at consumers. Catastrophic and not undoable.

Backup

  • SQLite database (db_path) — contains labels, reports, audit log, moderators, single-instance lease state. Regular backups via sqlite3 .backup or filesystem snapshot.
  • Signing key file (signing_key_path) — losing it means losing the labeler identity. Back up encrypted, store offline.
  • Session files are NOT backup-worthy. Operator and moderator sessions are re-created by running cairn operator-login / cairn login.

Moderator management

Manage moderator membership via the cairn moderator CLI. All three subcommands operate on the same SQLite DB the labeler runs against and load config the same way cairn serve does (--config <path> or CAIRN_CONFIG).

# Add a moderator with the standard role:
cairn moderator add did:plc:example --role mod --config /etc/cairn/cairn.toml

# Add an admin (gets the elevated `tools.cairn.admin.listAuditLog`
# permission per §F12):
cairn moderator add did:plc:example --role admin --config /etc/cairn/cairn.toml

# Change an existing moderator's role (errors without --update-role
# if the DID is already a moderator):
cairn moderator add did:plc:example --role admin --update-role --config /etc/cairn/cairn.toml

# Remove a moderator:
cairn moderator remove did:plc:example --config /etc/cairn/cairn.toml

# Removing the last admin is blocked unless --force:
cairn moderator remove did:plc:example --force --config /etc/cairn/cairn.toml

# List all moderators (tabular):
cairn moderator list --config /etc/cairn/cairn.toml

# Filter to a single role, or emit JSON for scripts:
cairn moderator list --role admin --json --config /etc/cairn/cairn.toml

The CLI runs as a one-shot — no server startup, no single-instance lease acquisition; it is safe to invoke while cairn serve is running against the same DB.

added_by semantics: CLI-initiated inserts leave the moderators.added_by column NULL — the CLI has no attested caller identity (no JWT iss, no signed request). It is populated only for HTTP-admin attribution via the moderator who made the change. Operators auditing membership history should read NULL as "added via CLI / direct DB write," not "unknown."

For emergencies when the CLI isn't available (e.g., bootstrapping the first admin before any binary is installed, or recovering from a corrupted invocation), the moderators table can be manipulated directly — the schema is in the initial migration and the design contract is §F12:

INSERT INTO moderators (did, role, added_at)
VALUES ('did:plc:example', 'admin', strftime('%s','now') * 1000);

Direct SQL skips the last-admin guard and the role-change prompts; reach for it only when the CLI path isn't an option.

Moderator authentication

Admin and moderator CLI subcommands (cairn report ..., cairn audit list, cairn retention sweep) require a logged-in moderator session. Authenticate once per machine:

cairn login \
    --cairn-server https://labeler.example \
    --pds https://bsky.social \
    --handle moderator.example.bsky.social

Prompts for the moderator's PDS app password — separate from the operator's app password (different identity, different credentials in production deployments). Caches a session file at ~/.config/cairn/session.json (mode 0600, owned by the running user — same §5.3 invariants as the operator session). The resolved DID must have a corresponding row in the moderators table on the target cairn-mod instance — see Moderator management above for adding rows.

To revoke: cairn logout.

Report management (§F17)

Admin-side report workflow via cairn report {list, view, resolve, flag, unflag}. All five subcommands wrap the tools.cairn.admin.* HTTP endpoints, so they require a logged-in session (cairn login) and a moderator-or-admin role row in the moderators table on the target cairn-mod instance.

# List pending reports.
cairn report list --status pending

# Filter by reporter; emit JSON for piping through jq.
cairn report list --reported-by did:plc:reporter --json

# Page through results via the cursor a previous response emitted.
cairn report list --cursor <c-from-prior-response>

# Inspect one report (full body included; admin-authenticated).
cairn report view 42

# Resolve a report without applying a label (the "dismiss" workflow).
cairn report resolve 42 --reason "not actionable"

# Resolve AND apply a label in one transaction.
cairn report resolve 42 \
  --apply-label-val spam \
  --apply-label-uri did:plc:offender \
  --reason "definitely spam"

# Suppress future reports from a noisy reporter; reverse with unflag.
cairn report flag did:plc:noisyreporter --reason "false reports"
cairn report unflag did:plc:noisyreporter

Audit attribution. Every mutating action (resolve, flag, unflag) is recorded in audit_log with the moderator's DID as the actor — taken from the JWT iss cairn-mod's session-auth produces. The CLI is HTTP-wrapped (not a direct DB tool) specifically so this attribution is correct; bypassing the HTTP path would write actor_did = NULL rows, corrupting the audit trail for exactly the events operators most want to reconstruct.

Pagination. Auto-pagination is intentionally out of scope. --cursor <c> is the operator's mechanism for chaining calls. JSON output includes a top-level cursor field when more results are available; human output appends a trailing next cursor: ... line.

Audit log queries (§F18)

Read-only audit log inspection via cairn audit list and cairn audit show <id>.

# Newest 50 entries.
cairn audit list

# Filter by actor / action / outcome.
cairn audit list --actor did:plc:moderator --action label_applied
cairn audit list --outcome failure

# Time-window scan (RFC-3339 inclusive bounds).
cairn audit list --since 2026-04-01T00:00:00Z --until 2026-05-01T00:00:00Z

# Page through; emit JSON for downstream tooling.
cairn audit list --limit 250 --cursor <c-from-prior-response> --json

# Fetch one entry by id.
cairn audit show 42
cairn audit show 42 --json | jq .reason

Admin role required. Moderators querying the audit log receive 403. The audit log records the moderator's own actions; read access to the full set is reserved for admins to avoid the "moderators silently auditing one another" pattern.

Read-only contract. The audit_log table has SQL triggers that abort UPDATE and DELETE; the CLI matches by exposing only read operations.

Service record verify on startup (§F19)

cairn serve performs a verify-only check at startup before binding the HTTP listener: the local [labeler] config is rendered into an app.bsky.labeler.service record, and its content-hash is compared against the published record at <operator.pds_url>/<service_did>. Drift, absent, or unreachable each fail-start with a distinct exit code so orchestrators (and operators) can branch.

  • Configs without a [labeler] block skip verify. If you're running a cairn-mod deployment that does NOT publish a service record (test harnesses, embedders, custom workflows), this gate doesn't apply and cairn serve starts normally. Operator-facing deployments always have [labeler].

  • Configs with [labeler] MUST also have [operator]. Verify needs operator.pds_url to know where to fetch the published record from. [labeler] declared without [operator] fail-starts as a USAGE-coded config error (real misconfig signal, not a drift gate).

Failure modes and exit codes:

Code Variant Meaning Operator action
12 SERVICE_RECORD_DRIFT Local config differs from PDS record Run cairn publish-service-record to update the PDS
13 SERVICE_RECORD_ABSENT No record published yet Run cairn publish-service-record to publish for the first time
14 SERVICE_RECORD_UNREACHABLE Could not reach PDS Transient infra issue; retry. If persistent, check operator.pds_url

The drift exit's stderr message names the fields that differ (label values, definition count, reason types, subject types). When all four match but content hashes differ, the message points at per-definition contents (severity / blurs / locales) as the drift surface to inspect.

Reconciliation flow. When verify fails with drift or absent, the operator runs cairn publish-service-record on the host that has operator credentials configured (see §5.3). After successful publish, restart cairn serve; verify passes on the next startup.

Lease handling. Verify happens AFTER the single-instance lease is acquired (so a verify failure doesn't waste a PDS fetch when another instance already holds the slot). On verify failure, the lease is released before serve exits, so a subsequent startup attempt isn't blocked.

No opt-out flag. v1.1 has no --skip-verify or equivalent. The whole point of the gate is to catch drift; a flag would re-introduce the drift class via forgetfulness. If a real emergency case surfaces post-launch, the project will weigh adding one as its own tracker entry.

Single instance per DID (§F5)

  • Only one cairn serve against a given DID at a time. cairn-mod enforces this with a SQLite-backed lease; the loser of a startup race exits with LEASE_CONFLICT (exit code 11). The contrib systemd unit sets RestartPreventExitStatus=11 so systemd doesn't restart-loop.
  • Even with the lease disabled, two instances against the same DB would corrupt the sequence space. Don't attempt it.

Monitoring (v1 surface is minimal)

  • systemd status + journalctl — errors and panics land here. The contrib unit sets StandardOutput=journal.
  • Disk usage on the db_path partition — cairn-mod has an app-level disk guard for the report path, but OS-level monitoring catches everything.
  • TLS certificate expiry at the reverse proxy — Caddy auto-renews via ACME; nginx + certbot needs its own cron check.

Health probes (§F14)

cairn-mod exposes two unauthenticated endpoints for orchestrators:

  • GET /health — liveness probe. Always returns 200 with {"status": "ok", "version": "..."} while the process can answer a request. No dependencies checked. Failure = restart the pod.
  • GET /ready — readiness probe. Returns 200 on all-ok, 503 on any check failure. Body is identical in both cases and names the individual checks (database, signing_key, label_stream). Failure = stop routing new traffic.
  • Verify both endpoints respond correctly against a running instance before first release. curl -s http://127.0.0.1:3000/health should return 200; curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/ready should return 200 when healthy and 503 when any check is intentionally failed (e.g., stop the writer, or point at an unreachable DB).

Dependency security scanning (§F15)

  • Most recent CI security scan on main is green. cargo-audit and cargo-deny both run on push to main and every PR; a scheduled daily cargo-audit opens a tracking issue when advisories land out-of-band. Before pointing real subscribers at the instance, verify the latest run on the version you're deploying passed.
  • No advisories ignored without dated rationale and review date. Open deny.toml and inspect [[advisories.ignore]]. Each entry must carry a reason naming why the risk is accepted, and a Review: YYYY-MM-DD comment ≤ 180 days out. grep -n 'Review:' deny.toml is the one-command audit. Entries without a review date, or with dates in the past, are a hygiene failure — either renew the review or remove the ignore.

Trust-chain disclosures

Operators AND subscribers should understand what cairn-mod's protocol guarantees and what it doesn't. These are v1 properties, documented in §4.2 of the design doc and summarized here per §14's "prominently placed" directive.

  1. Label trust is operator trust. A subscriber to this labeler's DID is implicitly trusting the current and past judgment of whoever controls that DID. If the operator silently swaps intent (becomes malicious, sells the DID, is compromised) there is no protocol-level mechanism for subscribers to detect this.

  2. Historical labels are forgeable by a malicious operator with DB access. v1's audit log records who/when/why at the application layer but isn't cryptographically linked to the labels table. An operator with direct SQLite access can rewrite history. v1.1's hash-chained audit log is a prerequisite (but not sufficient) for historical-label integrity.

  3. Single operator per instance is a single point of compromise. Operators concerned about unilateral label-history tampering should evaluate this limitation against their threat model. Mitigations (transparency logs, hash-chained audit) are tracked for future versions; specific mechanics are not yet finalized.

Architecture

  • Single-writer task (§F5) owns all write operations through an mpsc channel — sequence monotonicity, cts clamping, and signing all happen in one place.
  • Signed labels per §6.2 — DAG-CBOR canonical encoding, ES256K with RFC 6979 deterministic nonces, low-S enforced at emission. Parity with @atproto/api is pinned by a fixture corpus in tests/.
  • Single-instance lease (§F5) prevents two cairn-mod processes from signing labels against the same DID. Second cairn serve exits with a dedicated LEASE_CONFLICT code so systemd doesn't restart-loop.
  • Admin XRPC lives under tools.cairn.admin.* — the custom lexicons are embedded in the binary and served at /.well-known/lexicons/tools/cairn/admin/.

Everything deeper is in the design doc — threats, cryptographic details, schema migration policy, v1.1 roadmap.

Contributing

See CONTRIBUTING.md for build + test + PR workflow. Participation is governed by the Contributor Covenant.

Security

Vulnerabilities go to the private channel in SECURITY.md — not public issues.

License

Dual-licensed under MIT or Apache 2.0 at your option. Contributions are accepted under the same terms.