Repository navigation
Releases: skydeval/cairn-mod
Release list
v1.7.0 — PDS-side enforcement bridge & inbound XRPC gateway
[1.7.0] - 2026-04-30
v1.7 "PDS-side enforcement bridge & inbound XRPC gateway" closes
the loop between cairn-mod and the operator's PDS in two halves.
Outbound: a[pds_admin]config block declares operator-trusted
PDS credentials and an action-type-to-backend-method mapping;
cairn-mod's existing recordAction pipeline now propagates
account-state changes (takedown / temp suspension / restore) to
the configured PDS in lockstep with label emission. Inbound: a new
xrpc_gatewayaccepts proxiedtools.ozone.moderation.*calls
and PDS-forwardedcom.atproto.moderation.createReport— making
cairn-mod a usable Ozone replacement for operators on bsky-PDS.
Both halves preserve cairn-mod's audit-chain discipline (every
backend call hash-chains into the existing audit log; every
inbound mutation lands via the canonical recordAction path).
v1.7 ships bsky-PDS support; thePdsAdminBackendtrait
abstraction accommodates v1.8's Aurora-Locus backend without API
changes. Disabled by default — operators upgrading from v1.6 see
no behavior change unless they opt in.
Added
Changed
- [pds_admin] config block: parsing, validation, schema (#83)
- [pds_admin]
PdsAdminBackendtrait +BackendError+BackendActionId+Subjecttypes (#84) - [pds_admin]
pds_admin_audittable + unified hash chain spanningaudit_log(#85) - [pds_admin]
OzoneBackendskeleton: ctor + Basic-auth + xrpc-url helpers (#86) - [pds_admin]
OzoneBackend::takedown_accountbody viacom.atproto.admin.updateSubjectStatus+ recordAction dispatch (#87) - [audit]
cairn audit verifyextended to walk the unified chain acrossaudit_logandpds_admin_audit(#88) - [pds_admin]
OzoneBackend::suspend_account+restore_accountbodies; ISO-duration → days plumbing; revoke-action dispatch path (#89) - [pds_admin]
probe()trait method +OzoneBackend::probe(describeServer) + serve.rs startup wiring (#90) - [xrpc_gateway]
[xrpc_gateway]config block + module skeleton + 501 catch-all router (#91) - [xrpc_gateway] NSID allowlist enum + per-handler dispatch stubs + XRPC-shape 405 envelope (#92)
- [xrpc_gateway]
XrpcAuthService+ tower middleware: ATProto service-auth JWT verification (ES256K, claim validation; replay deferred to #94) (#93) - [xrpc_gateway] Replay cache +
xrpc_known_callers/xrpc_trusted_pdsesmembership tables + middleware composition + CLI subcommands; audit-verify extended to walk 4 tables (#94) - [xrpc_gateway]
tools.ozone.moderation.emitEventhandler body: dispatchesmodEventLabel/modEventTakedown/modEventReverseTakedown/modEventCommentinto the canonicalrecord_action/revoke_actionpipeline (§A14). EnforcescreatedBy == claims.issper §A8.1 defense-in-depth. Reservedxrpc-gateway-defaultreason code documented for events without naturalreason_codes(operators must declare in[moderation_reasons]). Unsupported$typevalues (Ozone has many beyond cairn-mod's four) return 400InvalidRequestnaming the unsupported type. (#95) - [xrpc_gateway]
com.atproto.moderation.createReporthandler body: PDS-signed inbound flow (§A10). Upstream PDSes forward user-filed reports; cairn-mod inserts into the existingreportstable for the §F11/§F12/§F17 resolution surface to handle unchanged. The lexicon'sreasonTypeis stored verbatim (cairn-mod'sreports.reason_typealready uses lexicon strings — no translation table). The user identity comes from the body'sreportedByfield (the PDS asserts it on behalf of the user); operators express trust in upstream PDSes by adding them toxrpc_trusted_pdses. Threat-model §4 entry 9 documents the transitive-trust expansion. (#96) - [xrpc_gateway]
tools.ozone.moderation.queryStatuseshandler body: paginated read endpoint that folds cairn-mod's action history (subject_actions+labels+reports) into Ozone'ssubjectStatusViewshape. Newhandlers/projections/submodule houses the field-by-field translation (the design-heavy piece) — pure functions, fully unit-tested. v1.7 supportssubject/limit/cursor/sortDirection/takendown/tags/appealedfilters; unsupported filters return 400InvalidRequestnaming the field rather than silently ignoring.appealed=truereturns empty (cairn-mod has no appeal flow);reviewStateis constant#reviewClosed. Cursor: base64url(JSON) of(updated_at_ms, subject_did, subject_uri)lex-comparable with the page query's sort key.XrpcGatewayStateextended withservice_didfor thelabels.srcfilter. (#97) - [xrpc_gateway]
tools.ozone.moderation.queryEventshandler body: paginated read endpoint that projects cairn-mod'saudit_log(joined withsubject_actions) into Ozone'smodEventViewshape. cairn-mod-internal audit entries are filtered out — the Ozone surface only seessubject_action_recorded/subject_action_revokedprojected tomodEventLabel/modEventTakedown/modEventComment/modEventReverseTakedown;pending_*,report_resolved,reporter_*,retention_sweep,service_record_*,label_applied/label_negatedare operator-tier and surface only via the CLI +cairn audit verify. Revocations of warnings/notes are also filtered (Ozone has no "reverse comment" event). v1.7 supported filters:subject/types/createdBy/sortDirection/createdAfter/createdBefore/limit/cursor/includeAllUserRecords; others → 400InvalidRequest. Cursor: base64url(JSON) of a singleaudit_log.id(simpler than #97's tuple cursor since the column is monotonic AUTOINCREMENT). Closes Phase D's inbound NSID surface for v1.7. (#98) - [xrpc_gateway] Retired the
build_routes_onlytest fixture: post-#98, every handler requiresExtension<XrpcGatewayState>, so the no-middleware variant is no longer testable in isolation. The router's structural shape tests (unknown-NSID fallback, case mismatch, wrong-method 405, two-field envelope) are migrated to the layered router viaspawn_authed. (#98) - [cli]
cairn pds-admin {takedown,suspend,restore}: manual escape hatch for the PDS-admin bridge (#87 / §F23 / §A13). HTTP-routed via the canonical recordAction / revokeAction admin XRPC; the writer's post-commit dispatch fires the configured backend automatically. Does not bypass strike accounting. Pre-flights[pds_admin].enabledand surfaces thepds_admin_auditoutcome in the response. Reserved reason codepds-admin-clifor manual escalations (operators must declare in[moderation_reasons]). (#99) - [cli]
cairn moderator add --with-xrpc-callers: convenience flag that adds the moderator DID toxrpc_known_callersin the same invocation, with--byrecording the operator running the command. Idempotent at the application layer (pre-checksis_known_callerto skip the duplicate add). Plaincairn moderator addis unchanged. (#99) - [cli]
cairn moderator events: operator-tier audit-events view that mirrorstools.ozone.moderation.queryEvents(#98) but exposes the FULL cairn-mod audit_log vocabulary (includingpending_*,retention_sweep,xrpc_*collaboration events,report_resolved, etc.). Default mode renders Ozone-eligible rows in their projected modEventView shape and cairn-mod-internal rows in a generic shape, intermixed in chronological order.--ozone-onlyapplies #98's filter-out policy; output is identical to what queryEvents would return for the same filters. Reuses theaudit_event::project_audit_eventprojection from #98. Direct-DB; supports--subject/--actor/--type/--from/--to/--limit/--cursor. (#99) - [docs] §F23 design-doc chapter — operator-facing reference for v1.7's PDS-side enforcement bridge and inbound XRPC gateway. 12 numbered subsections covering compatibility framing, outbound
pds_admin(trait surface, OzoneBackend, audit-chain integration, startup probe), inboundxrpc_gateway(NSID allowlist, 501/405 envelopes),XrpcAuthService(verification rules + replay cache), trust tables (xrpc_known_callersvsxrpc_trusted_pdses+ threat-model §4.9 cross-ref), inbound action integration + projection policy (subjectStatusView + modEventView field-by-field tables, filter-out policy, lexicon non-conformance notes), operator config blocks, operator-tier CLI surface (the five v1.7 commands), reserved reason codes (policy-threshold/xrpc-gateway-default/pds-admin-cli), operator-facing invariants (audit chain ordering, strict-monotonic timestamps, suspension duration encoding, strongRef.cid omission, replay cache scope, queryEvents filter-out), patterns established for v1.8+, and cross-references. (#100) - [docs] §18 roadmap update + §19.5 v1.7 deployment runbook. §18 collapses the v1.7+ foreshadowing bullet to a one-line shipped pointer to §F23 and refreshes the v1.x trajectory along two axes (backend coverage / Ozone parity floor); v1.8 = LocusBackend + retry policy + gateway refinements; v1.9 = review queue + extended event types + source management; v2.0 = web UI; future cycle = XRPC management of collaboration tables,
tools.ozone.communication.*/tools.ozone.team.*, action-time CIDs; enterprise-tier = multi-instance replay coordination + Postgres + multi-node. New §19.5 walks operators through enabling[pds_admin](action_map decision, env var, reserved reason code, restart, probe verification, manual-takedown verification), enabling[xrpc_gateway](service DID publication, bsky-PDS env var coordination, reserved reason code, seedingxrpc_known_callers+xrpc_trusted_pdses, probe call), the verification dance (cairn audit verify,cairn moderator events --ozone-only, end-to-end test), and the rollback path (disable + restart; rows preserved for re-enable). v1.7 doc-complete; release ceremony per §19.2 happens outside chainlink scope. (#101)
Fixed
rustfmtdrift inadmin_subject_actions.rs(7a7628f).DEFAULT_POLICY_REASON_CODErenamed frompolicy_thresholdto
policy-thresholdso the default ...
v1.6.0 — policy automation
[1.6.0] - 2026-04-27
v1.6 "Policy automation" closes the v1.5 loop: operators
declare strike-threshold rules in[policy_automation], and
the recorder evaluates those rules inside every recordAction
transaction. Auto-mode rules record consequent actions in the
same transaction; flag-mode rules queue pending rows for
moderator review. Conservative idempotency, severity-ordered
rule selection, takedown-cascade auto-dismissal of pendings,
and a forensic audit chain that extends across all policy
events. Pending state is moderator-tier visibility only — the
public surface still shows what cairn-mod has done, not what
it might do.
Added
- Policy automation engine:
[policy_automation]config block,
PolicyAutomationPolicyconfig loader, pure-function policy
evaluator with crossing detection + idempotency + severity
ordering, recorder integration evaluating rules inside the
recordAction transaction. (#70, #71, #72, #73) - Pending action confirm flow:
tools.cairn.admin.confirmPendingAction
XRPC +WriteCommand::ConfirmPendingActionwriter command.
Confirmed pendings materialize assubject_actionsrows with
actor_kind='moderator'(the moderator takes responsibility)
andtriggered_by_policy_rulepreserved as forensic
provenance; full label emission via the v1.5 path. (#74) - Pending action dismiss flow:
tools.cairn.admin.dismissPendingAction
XRPC +WriteCommand::DismissPendingActionwriter command.
Audit-only rationale storage (the pending table itself has no
resolved_reasoncolumn; rationale lives in the audit row's
moderator_reasonfield). (#75) - Takedown-cascade auto-dismissal: every unresolved pending for
a subject auto-dismisses inside the same transaction as a
takedown row INSERT, regardless of takedown path (moderator-
recorded, policy-auto-recorded, or confirmed-pending-promoted).
Cascade audit rows reuse thepending_policy_action_dismissed
audit_log.action and discriminate via reason JSON's
triggered_byfield (takedown_terminalvsmoderator_dismissed),
cross-referencing the triggering takedown viatakedown_action_id.
(#76) - Pending action read XRPC:
tools.cairn.admin.listPendingActions
(paginated,subject+resolutionfilters, opaque id-cursor)
andtools.cairn.admin.getPendingAction(single row). Mod-or-
Admin role; direct sqlx queries against the pool (no writer
task involvement). (#77) - Operator CLI:
cairn moderator pending {list, view, confirm, dismiss}, HTTP-routed via the admin XRPC, tabular human output
by default,--jsonfor tooling. (#78) - New error variants:
PendingActionNotFound,
PendingAlreadyResolved,SubjectTakendown(defensive race-
closer on confirm). - New audit_log.action vocabulary:
pending_policy_action_confirmed,
pending_policy_action_dismissed. Pending creation rides the
precipitating action'ssubject_action_recordedaudit row's
policy_consequencefield (no separatepending_policy_action_created
audit kind). - Migration
0005_policy_automation.sql: extendssubject_actions
withactor_kind(CHECK in 'moderator' | 'policy', defaulting
to 'moderator' for backfill) andtriggered_by_policy_rule
(NULL for moderator-recorded actions); newpending_policy_actions
table with write-once-on-resolution trigger; partial indexes
on the active subset for the moderator review queue. - Comprehensive integration test coverage: idempotency contracts
through the writer task (#80) and end-to-end lifecycle
scenarios composing all v1.6 surfaces (#81). - Design doc §F22 (policy automation; 11 subsections covering
rule shape, threshold-crossing semantics, severity ordering,
auto-vs-flag mode, pending resolution, takedown cascade, schema- audit linkage, synthetic policy actor DID, public-tier non-
visibility, operator surfaces, deferred capabilities). New §4.2
disclosure 6 (pending visibility is moderator-tier only). §F21.9 - §18 roadmap updates. (#82)
- audit linkage, synthetic policy actor DID, public-tier non-
Changed
- Pending policy actions are moderator-tier visibility only — not
exposed via public XRPC (tools.cairn.public.getMyStrikeState
is unchanged from v1.5). Subscribers see what cairn-mod has
done, not what cairn-mod might do. See §4.2 disclosure 6. subject_actionsaudit row's reason JSON gains anactor_kind
discriminator ('moderator'vs'policy') and an optional
triggered_by_policy_rulefield. The precipitating action's
audit row also gains an optionalpolicy_consequencefield
({rule_fired, mode, auto_action_id | pending_action_id})
cross-referencing the consequence when a rule fires.pending_policy_action_dismissedaudit reason JSON carries a
triggered_bydiscriminator ('moderator_dismissed'for #75,
'takedown_terminal'for #76) so audit consumers can filter
the two shapes viajson_extract.MAINTAINERS.mdadds a "Development pattern" section disclosing
the AI-assisted development approach used to ship cairn-mod.
Fixed
- rustfmt drift in
tests/admin_subject_actions.rsfrom #76's
test rewrites — twoletbindings that were left in unwrapped
two-line form. Pure formatting fix; no logic change. (7a7628f)
Internal
- chainlink #79 closed as duplicate of #73; the
PolicyAutomationPolicyplumbing through writer-state landed
as part of #73's recorder integration per that session's
"subsume #79" decision.
v1.5.2 — Document cairn moderator labels
[1.5.2] — 2026-04-27
Closes the v1.5 documentation gap:
cairn moderator labelsis
now in the moderator CLI reference.
Changed
- Added documentation for the
cairn moderator labelssubcommand
to docs/moderator-cli.md as a new "Active label inspection"
section. The subcommand shipped in v1.5 (#66) but was never
added to README.md before v1.5.1's documentation split
preserved existing content verbatim. (#69)
v1.5.1 — Documentation reorganization
[1.5.1] — 2026-04-27
Documentation reorganization for clearer audience separation —
no behavior changes.
Changed
- Split README.md into focused documents covering distinct
audiences. README.md retains discovery-tier content (project
pitch, status, trust-chain disclosures, architecture summary)
and slims to ~150 lines. New SETUP.md covers first-deployment
setup (install, signing key, configuration, bootstrap, service-
record verify). New OPERATIONS.md covers day-2 operator content
(production checklist, monitoring, dependency security scanning,
single-instance enforcement). New docs/moderator-cli.md is the
moderator CLI reference (membership management, login, report
workflow, audit log queries). Existing files (CHANGELOG,
CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, MAINTAINERS, LICENSE-*,
cairn-design.md) stay at root per their conventions. (#68)
v1.5.0 — Label emission
[1.5.0] - 2026-04-27
v1.5 "Label emission" closes the v1.4 loop: every recorded
action now translates into ATProto labels that consumer AppViews
honor, and revocation atomically negates whatever was emitted.
Operators declare the action-to-label mapping in
[label_emission]— defaults ship out of the box, override
knobs cover val / severity / blurs / locales per action type,
and notes never emit (defense-in-depth at the resolver). Reason
labels emit asreason-<code>alongside their action label,
sharing its expiry on temp_suspension. Revocation reads the
stored val from the linkage table (not the current policy)
so operator policy edits between emission and revocation can't
desynchronize negation. Subjects can introspect their own active
labels viatools.cairn.public.getMyStrikeState's new
activeLabelsfield; operators query the same surface via
cairn moderator labels <subject>. The new §4.2 disclosure 5
makes the trust-chain framing explicit: internal moderation
state and protocol-visible labels are different surfaces, both
observable.
Added
- Label-emission schema migration:
subject_actions.emitted_label_uricolumn (the action label's val — the column name predates the realization that ATProto labels lack canonical URIs; locked) andsubject_action_reason_labelslinkage table with composite PK(action_id, reason_code). Trigger update permits the single NULL→non-NULL transition foremitted_label_uri, mirroring the revocation columns' exception from #46. Linkage rows preserved across revocation as forensic record per §F21.7 (#57) [label_emission]config block +LabelEmissionPolicyruntime loader. Operator surfaces:enabledmaster toggle,warning_emits_labelopt-in,emit_reason_labelsreason gate,reason_label_prefix(default"reason-", empty permitted with startup warning),[label_emission.action_label_overrides.<type>]for per-action val/severity/blurs/locales,[label_emission.severity_overrides]for severity-only overrides. Cross-actionvaluniqueness enforced at config load — labels need to discriminate by val for revocation routing per §F21.1 (#58)- Action-to-label translation core:
resolve_action_labelsandresolve_reason_labelspure functions translate anActionForEmissionplus the resolved policy into unsignedLabelDrafts. Same shape as the v1.4 calculators (#49 strike, #50 decay, #51 window) — no I/O, no async, no signing, no DB. Notes never emit (hard gate); warnings gated onwarning_emits_label; reason labels share the warning's suppression gate (reasons-without-context confuses consumers, recovery path is asymmetric). TempSuspension propagatesexpires_atto both action label and reason labels per §F21.2 (#59) - Recorder integration:
handle_record_actionnow signs and persists the configured ATProto labels in the same transaction as thesubject_actionsINSERT, thesubject_strike_statecache UPSERT, and the audit_log row. Atomic — failure rolls back action + audit + labels together so the audit chain never claims emission that didn't happen. Audit reason JSON gainsemitted_labels: [{val, uri}, ...]capturing every label this action produced; hash chain (#39) extends to lock the (action, labels) bundle per §F21.7 (#60) - Revocation negation:
handle_revoke_actionnow atomically emits negation labels (neg=true) for every label the original action emitted, targeting the same(src, uri, val)tuple. Val read fromsubject_actions.emitted_label_uriandsubject_action_reason_labelsrows, NOT from current policy resolution — operator policy edits between emission and revocation cannot desynchronize negation. Negations carryexp = None(negations are permanent statements that supersede the original; expiring them would resurrect the original in consumer caches). Negation is unconditional regardless of current policy state — prior emissions exist on the wire and must be negated even when emission has been disabled since recording. Audit reason JSON gainsnegated_labels: [{val, uri}, ...]mirroring emission'semitted_labelsshape per §F21.3 (#62) - Idempotency guards: defense-in-depth
should_skip_action_label_emissionandshould_skip_reason_emissionhelpers gate the emission loops on the row's pre-emission state. v1.5's normal flow always finds the gates' queries returning empty/NULL (the INSERT just landed inside the same transaction), so the guards are structurally a no-op in production; they exist to protect against future paths (backfill migrations, retry helpers) where a row might already carry emission state. Thesubject_action_reason_labelsPK on(action_id, reason_code)is the SQL-level safety net per §F21.5 (#64) - Public XRPC
subjectStrikeState.activeLabels:tools.cairn.public.getMyStrikeStateandtools.cairn.admin.getSubjectStrikesnow return the labels cairn-mod is currently emitting against the subject. One entry per non-revoked, non-negated action withval,actionId,actionType,reasonCodes, optionalexpiresAt. Most-recent-action-first ordering. Cache-bypass invariant from §F20.9 extends here — always recomputed fromlabels+subject_actionssource-of-truth. Exp-passed labels are INCLUDED (cairn-mod surfaces emitted state; AppView-side honor ofexpis the consumer's responsibility per §F7) per §F21.8 (#65) - Operator CLI:
cairn moderator labels <subject>HTTP-routes via admingetSubjectStrikesand rendersactiveLabelsas the primary output. Default tabular human format (one row per emitted label — action label plus one per reason code, all sharing action context columns);--jsonemits just theactiveLabelsarray, not the full strikes envelope per §F21.8 (#66) - Test pinning: warning/note emission policy contract (the
warning_emits_labelgate at all relevant configurations + the Note hard gate at every code path) (#61); temp_suspension exp-field semantics (validation rejection paths + label-exp propagation including ms-precision RFC-3339 ↔ epoch-ms parity) (#63)
Changed
cairn-design.mdgains §F21 (label emission against moderation state), nine subsections covering action-to-label mapping, reason labels, negation on revocation, temp suspension expiry via ATProto's nativeexpfield, idempotency, customization for deployments, schema linkage and audit-log integration, public introspection and operator CLI, and deferred future work. §4.2 trust-chain disclosure 5 documents that internal moderation state and protocol-visible labels are different surfaces, both observable: operators declare the translation rules in[label_emission], subscribers compare config + emitted streams to verify policy variation. §F20.10 reordered: label emission marked shipped; remaining items reordered for v1.6+. §18 roadmap marks v1.5 shipped, adds v1.6 (policy automation) and v1.7+ (PDS administrative actions, default-disabled when[pds_admin]is absent), notes the continued v1.x trajectory toward Ozone parity, and contemplates cairn-mod-enterprise as eventual platform-tier sibling project (open scope; no version commitment) (#67)
Fixed
Removed
Security
v1.4.0 — Account moderation state model
[1.4.0] - 2026-04-26
v1.4 "Account moderation state model" turns moderation into
first-class records: every action against a subject (warning,
note, suspension, takedown) writes a structured row with a
strike value resolved at action time and frozen for forensic
durability. Strikes accumulate, dampen for first-time offenders,
and decay over time per operator-configurable rules. Read
endpoints — admin and a new user-facingtools.cairn.public.*
namespace — recompute strike state through a pure decay
calculator on every fetch, so cached values can never produce a
misleading answer. Operators declare reason vocabularies and
strike policy in[moderation_reasons]and[strike_policy]
config blocks; the new §4.2 disclosure 4 makes the trade-off
explicit: cairn-mod's contribution is making policy declarable
and observable, not adjudicating what the policy should be.
Added
- Account moderation state model:
subject_actionstable records every moderation action (warning, note, temp_suspension, indef_suspension, takedown) with structured reason metadata, duration, notes, and links to source reports.subject_strike_statecache table tracks current strike counts per subject_did. Append-only schema; revocation transitions are the only allowed UPDATE per the trigger contract from §F20.6 (#46) - Reason vocabulary system: operators declare moderation reasons in
[moderation_reasons]config block withbase_weight,severeflag, anddescription. Cairn-mod ships eight default reasons aligned with ATProto'sreasonType(hate-speech, harassment, threats-of-violence, csam, spam, misinformation, nsfw, other). Operator-declared blocks replace defaults entirely (no merging) per §F20.2 (#47) - Strike policy system:
[strike_policy]config block declaresgood_standing_threshold(default 3),dampening_curve(default[1, 2]),decay_function(linear or exponential),decay_window_days(default 90),suspension_freezes_decay(defaulttrue), andcache_freshness_window_seconds(default 3600). Per-field defaults let operators declare partial blocks per §F20.3 (#48, #55) - Strike calculator: pure function applies dampening at action time. Users in good standing get curve-position values; users out of good standing get full
base_weight; severe reasons bypass dampening. Thewas_dampenedflag andstrikes_at_time_of_actionare frozen on the row for forensic auditability per §F20.3 (#49) - Decay calculator: time-based decay computed on read, not stored. Linear decay reaches 0 at
decay_window_days; exponential decay reaches ~1% at the same boundary (half-life = window / log₂(100)). Suspension freezes decay (v1.4 simplification: only the most recent unrevoked suspension affects calculation) per §F20.4 (#50) - Recorder + revoker:
WriteCommand::RecordActionandWriteCommand::RevokeActionroute action writes through the writer task. Single-transaction atomicity acrosssubject_actionsrow,subject_strike_statecache update, and hash-chainedaudit_logrow via #39's pathway. Predict-then-verify pattern on thesubject_actions.idensuresaudit_log_idlinkage stays correct even under sequence-allocation edge cases (#51) - Position-in-window calculator: pure function counts in-good-standing offenses within the current decay window. Uses each prior action's
was_dampenedflag as the "in good standing at its time" predicate so position counting is stable across policy edits (#51) - Multi-reason resolver: when an action carries multiple reason codes, the strike calculation uses the dominant reason — severe wins regardless of
base_weight; ties onbase_weightresolve to first-listed deterministically (#51) - Admin XRPC:
tools.cairn.admin.recordAction,tools.cairn.admin.revokeAction(writes);tools.cairn.admin.getSubjectHistory,tools.cairn.admin.getSubjectStrikes(reads). All Mod-or-Admin authorization. The sharedsrc/server/strike_state.rsmodule factors the projection logic used by both admin and public read endpoints (#51, #52, #53) - Public XRPC:
tools.cairn.public.getMyStrikeState. First endpoint in thetools.cairn.public.*namespace. Service-auth gated; the verifiedissmust equal the subject_did. CORS allows browser-side callers (the namespace is designed for downstream consumers like future accessory bots or Web UIs). Cross-references admin'ssubjectStrikeStatetype to avoid type drift (#54) - Operator CLIs:
cairn moderator action/warn/note/revoke/history/strikes— moderator-tier, HTTP-routed via admin XRPC, cursor-paginated history, structured strikes display with decay trajectory. ThedecayWindowRemainingDaysfield is omitted at zero strikes since trajectory is meaningless without strikes to project (#51, #52) - Subject-strike-state cache management:
cache_is_freshpredicate andget_or_recompute_strike_countentry point. Cache bypass is the v1.4 read-endpoint invariant; the cache exists for v1.5+ consumers needing O(1) "is this user in good standing?" reads. Best-effort cache writes during recompute (write failure logs but doesn't fail the read) per §F20.9 (#55)
Changed
cairn-design.mdgains §F20 (account moderation state model), ten subsections covering action types, reasons, strike calculation, decay, revocation, schema, XRPC surface, operator CLIs, cache management, and deferred future work. §4.2 trust-chain disclosure 4 documents that operators set their own moderation policy and that policy declarability is cairn-mod's contribution rather than a fixed moderation philosophy. §18 roadmap updated to mark v1.4 as shipped and surface the deferred-to-future-releases items from §F20.10 (#56)cairn-design.md§F10 audit-log action vocabulary gainedsubject_action_recordedandsubject_action_revokedentries (lexicondefs.jsonknownValues+AUDIT_ACTION_VALUES+ design-doc prose). Update landed with #51's commit since the recorder writes those actions (#51)
Fixed
Removed
Security
v1.3.0 — Audit integrity
[1.3.0] - 2026-04-26
v1.3 "Audit integrity" makes audit-log tampering cryptographically
detectable. Every audit row now carries a SHA-256 hash chained to
the previous row's hash; operators verify chain integrity via
cairn audit verify, backfill pre-v1.3 rows viacairn audit-rebuild,
and inspect individual hashes via the extendedcairn audit show
output. The release also reconciles the design doc against four
releases of drift — §11/§14/§16.1/§18 reflect what shipped, §19's
release runbook documents the manual flow that v1.1/v1.2/v1.3
actually used, and the unused GitHub Actions release workflow is
marked deprecated.
Added
- Hash-chained audit log: every
audit_logrow carriesprev_hashandrow_hashcolumns (SHA-256 over DAG-CBOR canonical encoding of the row's content). Tampering with any row's content or stored hash produces a recomputation mismatch detectable viacairn audit verify.WriteCommand::AppendAuditroutes audit-row writes through the writer task; cross-process callers (cairn publish-service-record/cairn unpublish-service-record) use a parallelappend_via_poolpath that shares the samecompute_audit_row_hashfunction — single canonical hash implementation, no risk of drift between paths (#39) cairn audit-rebuildCLI subcommand. One-shot operator command that walksaudit_login id order and fillsprev_hash+row_hashfor every row using the canonical hash function. Idempotent — re-running on an already-rebuilt log is a no-op success. Acquires the writer'sserver_instance_leasefor the duration of the rebuild; lease conflict surfaces as exit 11LEASE_CONFLICTso the operator stopscairn servefirst. The §F10audit_log_no_updatetrigger is dropped + recreated inside a singleBEGIN IMMEDIATEtransaction so partial-failure ROLLBACK restores the trigger atomically (#40)cairn audit verifyCLI subcommand. Read-only operator command that walks the chain, recomputes each attested row's hash from its stored content + the running prev_hash, and compares against the storedrow_hash. Reports the first divergence (row id, expected hex hash, actual hex hash, count of rows verified before divergence) and exits with the new exit code 15AUDIT_DIVERGENCE. Pre-attestation rows (NULLrow_hash, predatingcairn audit-rebuildon a legacy install) are skipped with a horizon notice rather than flagged as errors. Safe to run whilecairn serveis live — read-only, no lease (#41)cairn audit show <id>output gainsrow_hashandprev_hashfields in both human and JSON output. Pre-attestation rows display the(pre-attestation)sentinel; the genesis row'sprev_hashdisplays as the all-zeros 64-char hex string.tools.cairn.admin.defs#auditEntrygains optionalprevHashandrowHashfields;tools.cairn.admin.listAuditLogalso exposes them on the wire (the human-table formatter stays terse), socairn audit list --json | jqsurfaces hashes for free (#42)
Changed
cairn-design.md§11/§14/§16.1/§18 reconciled to reflect what shipped through v1.0/v1.1/v1.2/v1.3 versus what's still aspirational. §18 renamed from "v1.1 Roadmap" to "Future Roadmap" (anchor moves from#18-v11-roadmapto#18-future-roadmap). Cross-platform binary commitments (Windows + multi-target) dropped from §14 and §16.1 — cairn-mod is server software designed for Linux deployment behind a reverse proxy, andcargo install cairn-modis the canonical install path (#43)cairn-design.md§19 release runbook rewritten to match the manual flow that v1.1, v1.2, and v1.3 have actually used: a 13-step procedure across three phases (Readiness, Manual end-to-end verification, Release ceremony). Cadence-bound framings ("1 week before target date," "Week-1 post-release") replaced with cadence-agnostic language; §19.4 now defers to §20.2's existing monitoring SLA rather than duplicating it (#44).github/workflows/release.ymlmarked deprecated via header comment. The workflow has never been used successfully — three v1.0workflow_dispatchattempts failed on 2026-04-24, and v1.1/v1.2/v1.3 all shipped via the manual flow now documented in §19.2. The file is preserved (not deleted) as historical record of the v1.0 release-automation design intent (#45)
Fixed
Removed
Security
v1.2.0 — Trust-chain transparency
[1.2.0] - 2026-04-26
v1.2 "Trust-chain transparency" makes the labeler's trust posture
auditable. Operators and external auditors can now read the full
signing-key history, maintainer roster (with HTTP-attested vs CLI-
inserted provenance), service record content hash, and instance
metadata via a single admin endpoint. The audit log gains a per-id
detail view to complement the existing list query, and the service-
record lifecycle gains its inverse —cairn unpublish-service-record
— closing a documented friction point in the operator workflow.
Added
tools.cairn.admin.getTrustChainadmin XRPC endpoint andcairn trust-chain showCLI subcommand. Read-only, admin-role-only summary of instance trust posture: signing-key history (active + rotated, withvalidFrom/validTo), maintainer roster withprovenanceAttestedflag distinguishing HTTP-attested adds from CLI/SQL inserts, published service-record content hash + declared label values, and instance metadata (build version, service endpoint). The envelope reuses thetools.cairn.admin.defsshared types so the CLI and any other consumer agree on wire shape (#35, #36, #37)tools.cairn.admin.getAuditLogadmin XRPC endpoint andcairn audit show <id>CLI subcommand. Per-id detail view complementingcairn audit list(the v1.1 list query). Admin-role-only; returns the bareauditEntryshape with the fullreasonpayload.AuditEntryNotFound404 on unknown id mirrorsgetReport's posture (#26)cairn unpublish-service-recordCLI subcommand. Removes theapp.bsky.labeler.servicerecord from the operator's PDS viacom.atproto.repo.deleteRecord(withswapRecordfor race detection), clearsservice_record_*labeler_configstate, and writes aservice_record_unpublishedaudit row in one transaction. Idempotent — running on an unpublished labeler is a no-op success that still audits. Subsequentcairn servestartup verify (§F19) fail-starts with the existing exit 13SERVICE_RECORD_ABSENTuntil republish; no new exit code needed (#34)
Changed
ReportStatusandResolutionActionextracted from string fields to typed Rust enums. Wire shape unchanged; the change is internal type safety + central enumeration of allowed values (#27)acquire_service_authandtruncatefactored from per-CLI-module copies into sharedsrc/cli/auth.rsandsrc/cli/output.rs. No behavior change; the factor-out triggered whencli/trust_chain.rsbrought the duplicatedacquire_service_authto eight identical copies across four modules (#28)- F10 audit-log actions list in cairn-design.md updated to include
service_record_unpublished(#34)
Fixed
tests/wellknown.rs::ALL_LEXICONSnow exercises every lexicon served at.well-known/lexicons/*—getTrustChain,retentionSweep, andgetAuditLogwere previously missing from the per-NSID serving test (coverage gap, not a correctness gap; the underlying handlers and routes were always tested) (#38)
Removed
Security
v1.1.0 — Pleasant to operate
v1.1 "Pleasant to operate" focuses on operational comfort for
self-hosters. The release adds orchestrator-friendly health
probes, supply-chain security scanning in CI, the full admin
CLI surface (moderator / report / audit / retention), startup-
time service-record drift detection, and a 41% trim of the
published crates.io tarball. The housekeeping pass deflakes
three timing-sensitive cache tests, validatescontrib/
deployment configs in CI, and ships a quickstart rot-check
that exercises the README's operator workflow against a mock
PDS — silent doc drift now fails CI.Also: this release reconciled a tracker numbering migration
mid-development (chainlink replaced an earlier crosslink
instance), and removed a stale auto-generated hook system that
was emitting misleading reminders. See
docs/tracker-history.md for the
migration record.
Added
/healthand/readyorchestrator probe endpoints (unauthenticated, per-check rationale in §F14) (#23)- CI security scanning:
cargo-audit+cargo-denyon push/PR plus a scheduled daily audit that opens an issue on new advisories; hard-fail posture with a dated-review-comment escape hatch indeny.toml(policy in §F15) (#13) cairn moderator {add, remove, list}CLI subcommands for managing themoderatorstable directly; one-shot, no lease conflict with runningcairn serve(contract in §F16) (#24)cairn report {list, view, resolve, flag, unflag}admin CLI subcommands wrapping thetools.cairn.admin.*HTTP endpoints; audit attribution preserved via JWT iss (contract in §F17) (#7)cairn audit listadmin-only CLI subcommand wrappingtools.cairn.admin.listAuditLogwith actor / action / outcome / time-window filters and--cursorpagination (contract in §F18) (#6)cairn servestartup verify-only check against the published service record on the operator's PDS; drift / absent / unreachable each fail-start with a distinct exit code (12/13/14); reconciliation viacairn publish-service-record(contract in §F19) (#8)- subscribeLabels retention sweep — daily writer-task batched DELETEs against
labelsolder than[subscribe].retention_days(default 180); operator-initiated runs viatools.cairn.admin.retentionSweep(admin-only, audited per call) andcairn retention sweep; new[retention]config block. Full contract in §F4 (#12) - E2E quickstart rot-check: new
tests/e2e/quickstart.shwalks the README's operator workflow end-to-end (signing-key generation → config →publish-service-record→serve→curl /.well-known/did.json) against a mock PDS binary. Newe2e-quickstartCI job — silent README drift now fails CI (#10) - contrib syntax smoke check: prototype
contrib-smokeCI job was developed during v1.1 but removed before release after four CI iterations surfaced a fundamental fragility — the validators (caddy validate/caddy adapt,nginx -t,systemd-analyze verify) are version- and environment-sensitive runtime tools rather than pure syntax checkers, making the job test "does CI's specific environment accept this template" rather than "is the template syntactically valid for operators." A cleaner replacement (likely pure syntax validation, not invoking runtime tools) is tracked as chainlink #33; the docker-compose end-to-end alternative tracks as chainlink #32. Side-effect of the removal:contrib/nginx/cairn.confships with rate-limiting as operator-add (matches the Caddyfile pattern) — the inlinerate=10r/hwas one of the four failures that drove this deferral and stays out as the v1.2 design conversation hasn't picked an approach (#9)
Changed
- crates.io tarball trimmed from 287 files to 168 via
Cargo.toml[package].excluderules — drops.chainlink/,.claude/,.github/, internal docs (cairn-design.md,RETROSPECTIVE.md,MAINTAINERS.md,CODE_OF_CONDUCT.md,docs/), andtests/(which alone account for ~70 files including the ~40-file signature corpus). The.sqlx/offline cache (~95 entries) is a hard floor required for downstreamSQLX_OFFLINE=truebuilds without sqlx-cli; further reduction would require splitting the cache into lib-only vs all-targets variants and is deferred (#22) - §20.4 of cairn-design.md replaced its three-paragraph "named handoff target TBD" narrative (stale post-v1.0) with a brief two-sentence pointer to MAINTAINERS.md as the durable source of truth for the archive-on-silence policy. Single-source-of-truth — no policy duplication (#17)
Fixed
- Three timing-sensitive auth-cache tests (
doc_cache_returns_cached_then_expires,doc_cache_negative_has_shorter_ttl,jti_cache_expiry_permits_reuse) deflaked by routing wall-clock reads through a newClocktrait. Production wiresSystemClock; tests substituteMockClockwith explicitadvance(Duration)calls. Zerothread::sleepin cache tests; deterministic regardless of CI scheduler jitter. Verified correctness power before commit by intentionally breakingDidDocCache::getandJtiCache::check_and_recordand confirming the relevant tests panic (#21)
Removed
Security
Cairn Mod v1.0.0
Cairn v1.0.0 - Initial Release
Cairn is a minimal, self-hostable AT Protocol labeler with audit integrity,
moderator authentication via PDS-mediated service auth, and single-binary
deployment backed by SQLite.
Features
- AT Protocol labeler service:
createLabel,queryLabels,subscribeLabels - PDS-mediated moderator authentication via
com.atproto.server.getServiceAuth - Admin XRPC surface:
applyLabel,negateLabel,retract,listLabels,
listReports,resolveReport,applyLabelAndResolveReport,listAuditLog - Full audit log with single-transaction atomicity
- Non-enumerating 404s (privacy-preserving)
- Configurable label taxonomy via TOML
- Single-binary deployment with SQLite backend
- Deployment templates for Caddy, nginx, systemd (see
contrib/)
Security
- Byte-exact DRISL signing parity with
@atproto/crypto - SSRF-hardened HTTP fetch
- File permissions enforced on signing keys and session files (0600)
- Signing key delivered only via file, never via environment variable
- Single-instance lease enforcement (SQLite-backed)
Documentation
- Production Checklist for operators (
README.md) - Design documentation with threat model (
cairn-design.md) - Full OSS paperwork: LICENSE (MIT + Apache), CONTRIBUTING, SECURITY,
CODE_OF_CONDUCT, MAINTAINERS
Known Issues
auth::cache::tests::doc_cache_negative_has_shorter_ttlhas a tight
timing-based assertion that can flake under CI load (tracked as
chainlink #21). Not user-facing.
Thanks
This is the first release. Documentation, architecture, and implementation
by @skydeval.