Skip to main content

cairn_mod/
config.rs

1//! Layered config loading.
2//!
3//! Precedence (lowest → highest): compiled-in defaults, then TOML file,
4//! then environment variables prefixed `CAIRN_`. CLI-flag overrides land
5//! once the clap surface grows subcommands.
6//!
7//! Per §5.1, signing-key private material is NEVER sourced through this
8//! path. `signing_key_path` points at a file; the bytes are read only by
9//! [`crate::signing_key::SigningKey::load_from_file`] with explicit
10//! permission, ownership, and env-var-reject checks.
11
12use figment::{
13    Figment,
14    providers::{Env, Format, Toml},
15};
16use serde::Deserialize;
17use std::collections::BTreeMap;
18use std::net::SocketAddr;
19use std::path::PathBuf;
20
21use crate::error::Result;
22
23/// Default bind address when `bind_addr` is absent from config.
24/// §F13: "expects reverse proxy for TLS." Loopback-only by default;
25/// operators knowingly opt into `0.0.0.0:3000` when they run without
26/// a reverse proxy on the same host.
27pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:3000";
28
29/// Top-level Cairn configuration.
30///
31/// Fields grow with features; the struct is `non_exhaustive` so additions
32/// are not a breaking change for downstream crates.
33#[derive(Debug, Clone, Deserialize)]
34#[non_exhaustive]
35pub struct Config {
36    /// The service DID Cairn runs as (§5.1).
37    pub service_did: String,
38    /// Publicly-reachable base URL where this Cairn instance serves HTTP
39    /// and WebSocket endpoints (e.g., `https://labeler.example`). Emitted
40    /// as the `serviceEndpoint` value in the `AtprotoLabeler` entry of
41    /// `/.well-known/did.json` so consumers can discover where to call.
42    ///
43    /// This is distinct from [`Self::bind_addr`] — typical production
44    /// deployments bind `127.0.0.1:3000` behind a reverse proxy but
45    /// advertise the public `https://labeler.example` URL here.
46    /// Validated at load time as a URL.
47    pub service_endpoint: String,
48    /// Where `cairn serve` binds its HTTP listener. Defaults to
49    /// [`DEFAULT_BIND_ADDR`] (`127.0.0.1:3000`) if omitted.
50    #[serde(default = "default_bind_addr")]
51    pub bind_addr: SocketAddr,
52    /// SQLite database file. Parent directory must exist; the file
53    /// itself is created on first run by
54    /// [`crate::storage::open`] alongside embedded migrations.
55    pub db_path: PathBuf,
56    /// Signing key file (§5.1). Mode 0600, owned by the running user,
57    /// hex-encoded 32-byte secp256k1 private key. Env-var delivery of
58    /// the key material is explicitly rejected — see
59    /// [`crate::signing_key::SIGNING_KEY_ENV_REJECTED`].
60    pub signing_key_path: PathBuf,
61    /// Admin-endpoint policy (§F12). Defaults to an empty table,
62    /// meaning `admin.applyLabel` accepts any label value ≤128 bytes
63    /// (matches the existing [`crate::AdminConfig`] default).
64    #[serde(default)]
65    pub admin: AdminConfigToml,
66    /// Labeler policy (§F1) — the `app.bsky.labeler.service` record
67    /// content. Required for `cairn publish-service-record`; other
68    /// subcommands don't consume it, so it's optional at load time.
69    /// When absent, `publish-service-record` surfaces a clear error.
70    #[serde(default)]
71    pub labeler: Option<LabelerConfigToml>,
72    /// Operator PDS auth surface (§F1 service record publishing).
73    /// The operator's identity is the DID that OWNS the labeler
74    /// account — distinct from moderators who authenticate to Cairn
75    /// (§5.2) and distinct from Cairn's own signing key (§5.1).
76    #[serde(default)]
77    pub operator: Option<OperatorConfigToml>,
78    /// Retention sweep execution policy (§F4 sweep task). Holds
79    /// schedule + batching knobs only — the cutoff itself
80    /// (`retention_days`) is owned by [`crate::SubscribeConfig`] so
81    /// the read-side floor and the sweep cutoff stay tied to a
82    /// single source of truth. Defaults match §F4 prose: enabled,
83    /// 04:00 UTC, 1000-row batches.
84    #[serde(default)]
85    pub retention: RetentionConfigToml,
86    /// Reason vocabulary for the v1.4 graduated-action moderation
87    /// model (§F20, #47). TOML projection of the operator's
88    /// `[moderation_reasons.<identifier>]` blocks; resolve to a
89    /// runtime [`crate::moderation::reasons::ReasonVocabulary`] via
90    /// `ReasonVocabulary::from_config`.
91    ///
92    /// Three states:
93    /// - `None` — operator declared no blocks; the resolver loads
94    ///   shipped defaults (eight reasons covering common categories).
95    /// - `Some(empty)` — operator wrote a bare `[moderation_reasons]`
96    ///   header with no sub-blocks; rejected at validate time as a
97    ///   probable typo.
98    /// - `Some(non_empty)` — operator's vocabulary is the complete
99    ///   set; defaults are NOT merged in.
100    #[serde(default)]
101    pub moderation_reasons: Option<BTreeMap<String, ReasonDefToml>>,
102    /// Strike policy for the v1.4 graduated-action moderation
103    /// model (§F20, #48). TOML projection of the operator's
104    /// `[strike_policy]` block; resolve to a runtime
105    /// [`crate::moderation::policy::StrikePolicy`] via
106    /// `StrikePolicy::from_config`.
107    ///
108    /// Two states:
109    /// - `None` — operator declared no block; the resolver returns
110    ///   shipped defaults (threshold 3, curve [1, 2], linear decay
111    ///   over 90 days, suspensions freeze decay).
112    /// - `Some(_)` — partial or full operator declaration. Per-field
113    ///   serde defaults fill any unspecified sub-fields; the
114    ///   resolved values are validated together (curve length
115    ///   convention, strict-ascending curve, positive decay window).
116    #[serde(default)]
117    pub strike_policy: Option<StrikePolicyToml>,
118    /// Label-emission policy for the v1.5 graduated-action moderation
119    /// model (§F21, #58). TOML projection of the operator's
120    /// `[label_emission]` block; resolve to a runtime
121    /// [`crate::labels::policy::LabelEmissionPolicy`] via
122    /// `LabelEmissionPolicy::from_config`.
123    ///
124    /// Two states:
125    /// - `None` — operator declared no block; the resolver returns
126    ///   shipped defaults (emission enabled, reason labels emitted,
127    ///   warnings not emitted, default per-action mappings —
128    ///   `!takedown`, `!hide`, `!warn`).
129    /// - `Some(_)` — partial or full operator declaration. Per-field
130    ///   serde defaults fill any unspecified sub-fields; the
131    ///   resolved values are validated together (label-value naming
132    ///   conventions, no-collision across action_type overrides,
133    ///   valid action_type keys in override maps).
134    #[serde(default)]
135    pub label_emission: Option<LabelEmissionPolicyToml>,
136    /// `[policy_automation]` block (§F22, #71). TOML projection of
137    /// the operator's policy-automation surface; resolve to a
138    /// runtime [`crate::policy::automation::PolicyAutomationPolicy`]
139    /// via `PolicyAutomationPolicy::from_config`. Cross-validation
140    /// against `[moderation_reasons]` (rule reason_codes must exist
141    /// in the operator's vocabulary) lives at the [`Config::validate`]
142    /// level, not on the resolver, mirroring the v1.4 / v1.5
143    /// per-block-resolver convention.
144    ///
145    /// Two states:
146    /// - `None` — operator declared no block; the resolver returns
147    ///   shipped defaults (engine enabled, empty rule set — the
148    ///   engine evaluates each recordAction and finds nothing to
149    ///   fire).
150    /// - `Some(_)` — operator-declared rules. Each rule gets
151    ///   per-field validation (positive threshold, valid
152    ///   action_type, valid mode, duration only on temp_suspension,
153    ///   reason_codes match the `[moderation_reasons]` vocabulary).
154    #[serde(default)]
155    pub policy_automation: Option<PolicyAutomationPolicyToml>,
156    /// `[pds_admin]` block (§F23, #83, v1.7). TOML projection of
157    /// the operator's PDS-admin outbound bridge config; resolves
158    /// to a runtime [`crate::pds_admin::PdsAdminPolicy`] via
159    /// `PdsAdminPolicy::from_config`.
160    ///
161    /// Two states:
162    /// - `None` — operator declared no block; the resolver returns
163    ///   the disabled default (engine off; backend unconfigured;
164    ///   action_map empty).
165    /// - `Some(_)` — partial or full operator declaration. When
166    ///   `enabled = true`, exactly one backend subsection must be
167    ///   present (v1.7 supports `[pds_admin.ozone]` only); the
168    ///   `[pds_admin.action_map]` table must cover every cairn-mod
169    ///   action type. When `enabled = false`, subsections may be
170    ///   present (forward-compat) but are not cross-validated.
171    ///
172    /// Cross-block validation against
173    /// [`crate::moderation::types::ActionType`]'s vocabulary lives
174    /// in [`Config::validate`] (every action_map key must parse
175    /// via `ActionType::from_db_str`); the resolver in
176    /// `crate::pds_admin::PdsAdminPolicy::from_config` handles
177    /// per-block validation (URL scheme, env-var resolution,
178    /// allowed-method set, with_lift_after gating).
179    #[serde(default)]
180    pub pds_admin: Option<PdsAdminConfigToml>,
181
182    /// `[xrpc_gateway]` block (§F23 inbound surface, #91, v1.7).
183    /// Operator-config-gated inbound XRPC listener for proxied
184    /// Ozone moderation calls + forwarded createReport calls.
185    /// Resolves to a runtime
186    /// [`crate::xrpc_gateway::XrpcGatewayConfig`] via
187    /// [`crate::xrpc_gateway::XrpcGatewayConfig::from_config`];
188    /// absence (the v1.6-shape default) leaves the gateway off
189    /// and the listener does not mount.
190    #[serde(default)]
191    pub xrpc_gateway: Option<crate::xrpc_gateway::XrpcGatewayConfigToml>,
192}
193
194/// TOML projection of [`crate::pds_admin::PdsAdminPolicy`] (§F23,
195/// #83, v1.7). Tops the `[pds_admin]` block. v1.7 ships
196/// bsky-PDS-only (`[pds_admin.ozone]`); `[pds_admin.locus]` is
197/// reserved for v1.8 and rejected at config-load with a clear
198/// "deferred to v1.8" message rather than silently ignored.
199///
200/// Unknown sibling subsections (anything other than the named
201/// fields and `locus`) are captured into [`Self::other_backends`]
202/// for the same reject-with-helpful-message treatment, so an
203/// operator typo (`[pds_admin.ozonee]`) surfaces at config load
204/// instead of silently disabling the bridge.
205#[derive(Debug, Clone, Default, Deserialize)]
206pub struct PdsAdminConfigToml {
207    /// Master toggle. `false` (default) — engine off; the
208    /// outbound bridge is not consulted on any recordAction.
209    /// `true` — engine on; exactly one backend subsection must
210    /// be present and the action_map must cover every cairn-mod
211    /// action type.
212    #[serde(default)]
213    pub enabled: bool,
214    /// `[pds_admin.ozone]` subsection — the bsky-PDS backend
215    /// config. Required when `enabled = true`. May be present
216    /// when `enabled = false` for forward-compat (the
217    /// per-block resolver still validates it).
218    #[serde(default)]
219    pub ozone: Option<PdsAdminOzoneToml>,
220    /// `[pds_admin.action_map]` subsection. Required when
221    /// `enabled = true`. Maps cairn-mod action types
222    /// (`takedown`, `temp_suspension`, etc.) to backend method
223    /// names. Each value is either a bare string (the method
224    /// name or `"skip"`) or a table (`{ method = "...",
225    /// with_lift_after = bool }`); see [`PdsAdminActionMapValueToml`].
226    #[serde(default)]
227    pub action_map: Option<BTreeMap<String, PdsAdminActionMapValueToml>>,
228    /// `[pds_admin.locus]` subsection — Aurora-Locus backend.
229    /// Reserved for v1.8; rejected at config-load in v1.7 with
230    /// a clear "deferred to v1.8" message. Parsed as opaque
231    /// JSON so v1.7 doesn't have to know the v1.8 schema; only
232    /// that the key was set.
233    #[serde(default)]
234    pub locus: Option<serde_json::Value>,
235    /// Catch-all for unrecognized backend subsection names
236    /// (e.g., a typo like `[pds_admin.ozonee]` or a future
237    /// `[pds_admin.kestrel]`). v1.7 rejects each at
238    /// config-load. The flatten attribute collects every
239    /// unmatched key under `[pds_admin]` here; the named
240    /// fields above (`enabled`, `ozone`, `action_map`, `locus`)
241    /// are consumed first.
242    #[serde(flatten, default)]
243    pub other_backends: BTreeMap<String, serde_json::Value>,
244}
245
246/// TOML projection of the bsky-PDS backend config (§F23, #83).
247/// Maps to the runtime
248/// [`crate::pds_admin::OzoneBackendConfig`] via
249/// [`crate::pds_admin::PdsAdminPolicy::from_config`], which:
250/// - parses `pds_url` via [`url::Url::parse`] and rejects
251///   non-https schemes (v1.7 is production-https-only — http
252///   support via a future `[server].dev_mode` flag is deferred);
253/// - reads the env var named by `admin_password_env` and
254///   rejects empty/unset values;
255/// - clamps `request_timeout_seconds` into `1..=60`.
256#[derive(Debug, Clone, Deserialize)]
257pub struct PdsAdminOzoneToml {
258    /// Base URL of the bsky-PDS this cairn-mod talks to. Must
259    /// parse via [`url::Url::parse`] and use the `https` scheme.
260    /// http support is deferred to a future `[server].dev_mode`
261    /// flag (#83 explicitly does NOT introduce that flag);
262    /// operators wanting local-mode testing in v1.7 should run
263    /// their bsky-PDS behind a localhost TLS proxy.
264    pub pds_url: String,
265    /// Name of the environment variable holding the PDS admin
266    /// password (the bsky-PDS Basic-auth credential). Empty or
267    /// unset values are rejected at config load. The variable's
268    /// value is read once at config load and resolved into the
269    /// runtime config; subsequent env mutations don't affect
270    /// the running process.
271    pub admin_password_env: String,
272    /// Per-request HTTP timeout in seconds. Defaults to 10 when
273    /// omitted; clamped to 1..=60 at validation time.
274    #[serde(default = "default_pds_admin_request_timeout_seconds")]
275    pub request_timeout_seconds: u32,
276}
277
278/// One entry in [`PdsAdminConfigToml::action_map`]. v1.7's TOML
279/// shape supports two forms — a bare method name string, or a
280/// `{ method, with_lift_after }` table. The table form is
281/// reserved for the v1.8 deferred-execution layer
282/// (`with_lift_after = true` triggers a follow-up restore call);
283/// v1.7 parses the table syntactically (forward-compat) but
284/// rejects `with_lift_after = true` at config load.
285///
286/// `serde(untagged)` handles the dual shape: try `Bare(String)`
287/// first; fall through to `Table` if the value is a TOML table.
288#[derive(Debug, Clone, Deserialize)]
289#[serde(untagged)]
290pub enum PdsAdminActionMapValueToml {
291    /// Bare-string form: just the method name. Most actions use
292    /// this form (`takedown = "takedown_account"`,
293    /// `warning = "skip"`).
294    Bare(String),
295    /// Table form, e.g. `temp_suspension = { method =
296    /// "takedown_account", with_lift_after = true }`. v1.7
297    /// rejects `with_lift_after = true` at validation; the
298    /// table form with `with_lift_after = false` (or omitted)
299    /// is equivalent to the bare-string form.
300    Table(PdsAdminActionMapTableToml),
301}
302
303/// The table-form variant of [`PdsAdminActionMapValueToml`].
304#[derive(Debug, Clone, Deserialize)]
305pub struct PdsAdminActionMapTableToml {
306    /// Backend method name. Same allowed set as the bare-string
307    /// form: `takedown_account` / `suspend_account` /
308    /// `restore_account` / `apply_label` / `negate_label` /
309    /// `skip`.
310    pub method: String,
311    /// When `true`, cairn-mod is expected to schedule a follow-up
312    /// `restore_account` call after the suspension expires.
313    /// **v1.7 rejects this at config load** — the deferred-
314    /// execution layer it requires is deferred to v1.8.
315    /// Defaults to `false` so absent + table-form-without-flag
316    /// is equivalent to the bare-string form.
317    #[serde(default)]
318    pub with_lift_after: bool,
319}
320
321fn default_pds_admin_request_timeout_seconds() -> u32 {
322    10
323}
324
325/// TOML projection of one entry in
326/// [`Config::moderation_reasons`]. The runtime equivalent
327/// (with validated identifier) is
328/// [`crate::moderation::reasons::ReasonDef`].
329#[derive(Debug, Clone, Deserialize)]
330pub struct ReasonDefToml {
331    /// Strike weight applied at action time, before dampening.
332    /// Validated at config-load time as `>= 1`.
333    pub base_weight: u32,
334    /// When `true`, this reason bypasses the dampening curve
335    /// regardless of the subject's standing. Defaults to `false`
336    /// when omitted from the TOML.
337    #[serde(default)]
338    pub severe: bool,
339    /// Operator-facing label describing what the reason means.
340    /// Required and non-empty; validated at config-load time.
341    pub description: String,
342}
343
344/// TOML projection of [`crate::moderation::policy::StrikePolicy`].
345/// Each sub-field carries a `#[serde(default = "...")]` so a partial
346/// `[strike_policy]` declaration fills the rest from the shipped
347/// defaults. The fully-defaulted struct matches
348/// [`crate::moderation::policy::StrikePolicy::defaults`].
349#[derive(Debug, Clone, Deserialize)]
350pub struct StrikePolicyToml {
351    /// Strike count at or below which a subject is in good standing.
352    /// Default 3. Validated together with `dampening_curve` —
353    /// curve length must equal `max(0, threshold - 1)`. See
354    /// [`crate::moderation::policy`] module docs for the worked
355    /// example.
356    #[serde(default = "default_good_standing_threshold")]
357    pub good_standing_threshold: u32,
358    /// Per-position dampening weights for in-good-standing offenses.
359    /// Default `[1, 2]`. Validated as strictly ascending with each
360    /// entry `>= 1`; length tied to `good_standing_threshold`.
361    #[serde(default = "default_dampening_curve")]
362    pub dampening_curve: Vec<u32>,
363    /// Decay shape applied to each action's strike contribution as
364    /// time passes. Default [`crate::moderation::policy::DecayFunction::Linear`].
365    /// Unknown variants fail at deserialize time.
366    #[serde(default = "default_decay_function")]
367    pub decay_function: crate::moderation::policy::DecayFunction,
368    /// Window over which `decay_function` operates, in days. Default
369    /// 90. Validated as `>= 1`; no upper bound.
370    #[serde(default = "default_decay_window_days")]
371    pub decay_window_days: u32,
372    /// When `true`, decay halts while the subject has an active
373    /// `indef_suspension` action. Default `true`.
374    #[serde(default = "default_suspension_freezes_decay")]
375    pub suspension_freezes_decay: bool,
376    /// How long the `subject_strike_state` cache row remains
377    /// "fresh" before a reader should recompute via the decay
378    /// calculator (#55). Default 3600 (1 hour). Validated `>= 1`.
379    /// Has no effect on v1.4 read endpoints, which always
380    /// recompute from source-of-truth regardless; ships ahead of
381    /// v1.5+ consumers that may read the cache directly.
382    #[serde(default = "default_cache_freshness_window_seconds")]
383    pub cache_freshness_window_seconds: u32,
384}
385
386impl Default for StrikePolicyToml {
387    fn default() -> Self {
388        Self {
389            good_standing_threshold: default_good_standing_threshold(),
390            dampening_curve: default_dampening_curve(),
391            decay_function: default_decay_function(),
392            decay_window_days: default_decay_window_days(),
393            suspension_freezes_decay: default_suspension_freezes_decay(),
394            cache_freshness_window_seconds: default_cache_freshness_window_seconds(),
395        }
396    }
397}
398
399fn default_good_standing_threshold() -> u32 {
400    3
401}
402fn default_dampening_curve() -> Vec<u32> {
403    vec![1, 2]
404}
405fn default_decay_function() -> crate::moderation::policy::DecayFunction {
406    crate::moderation::policy::DecayFunction::Linear
407}
408fn default_decay_window_days() -> u32 {
409    90
410}
411fn default_suspension_freezes_decay() -> bool {
412    true
413}
414fn default_cache_freshness_window_seconds() -> u32 {
415    3600
416}
417
418/// TOML projection of [`crate::labels::policy::LabelEmissionPolicy`]
419/// (§F21, #58). Each sub-field carries a `#[serde(default = "...")]`
420/// so a partial `[label_emission]` declaration fills the rest from
421/// shipped defaults. The fully-defaulted struct matches
422/// [`crate::labels::policy::LabelEmissionPolicy::defaults`].
423///
424/// `action_label_overrides` keys are action-type strings
425/// (`takedown`, `temp_suspension`, etc.) parsed at projection time
426/// against [`crate::moderation::types::ActionType::from_db_str`];
427/// invalid keys fail config load with a clear error rather than
428/// silently mapping to nothing.
429#[derive(Debug, Clone, Default, Deserialize)]
430pub struct LabelEmissionPolicyToml {
431    /// Master toggle. When `false`, no labels are emitted regardless
432    /// of other policy fields. Default `true`.
433    #[serde(default = "default_label_emission_enabled")]
434    pub enabled: bool,
435    /// Whether to emit `reason-<code>` labels alongside the action
436    /// label. Default `true`.
437    #[serde(default = "default_emit_reason_labels")]
438    pub emit_reason_labels: bool,
439    /// Whether `warning` actions emit a label. Default `false` —
440    /// warnings are typically advisory and don't surface to AppViews.
441    /// Operators that want to surface warnings (e.g., a "first
442    /// strike" community signal) flip this to `true`.
443    #[serde(default = "default_warning_emits_label")]
444    pub warning_emits_label: bool,
445    /// Prefix prepended to each `reason_code` to form the reason
446    /// label's `val`. Default `"reason-"` so e.g. `hate-speech`
447    /// becomes `reason-hate-speech`. Empty string permitted but
448    /// surfaces a startup warning.
449    #[serde(default = "default_reason_label_prefix")]
450    pub reason_label_prefix: String,
451    /// Per-action-type label override. Keys are action-type strings
452    /// (`takedown`, `temp_suspension`, etc.); values are full
453    /// [`LabelSpecToml`] declarations that replace the shipped
454    /// defaults for that action type.
455    #[serde(default)]
456    pub action_label_overrides: BTreeMap<String, LabelSpecToml>,
457    /// Per-action-type severity override. Keys are action-type
458    /// strings; values are severities. Use this when only the
459    /// severity needs adjustment without changing the label `val`.
460    /// Ignored for action types that have a full
461    /// [`LabelSpecToml`] in `action_label_overrides` — the explicit
462    /// override wins.
463    #[serde(default)]
464    pub severity_overrides: BTreeMap<String, SeverityToml>,
465}
466
467/// TOML projection of one entry in
468/// [`LabelEmissionPolicyToml::action_label_overrides`] (§F21, #58).
469/// Same field shape as [`LabelValueDefinitionToml`] minus the
470/// `identifier` (which is the action-type key in the parent map).
471#[derive(Debug, Clone, Deserialize)]
472pub struct LabelSpecToml {
473    /// The label `val` emitted for this action type. Validated as
474    /// 1..=128 bytes (§6.4 schema CHECK), lowercase ASCII letters +
475    /// digits + `-`, optionally prefixed with `!` for ATProto global
476    /// label values.
477    pub val: String,
478    /// Severity hint for consumer AppViews. Default
479    /// [`SeverityToml::Alert`].
480    #[serde(default = "default_label_spec_severity")]
481    pub severity: SeverityToml,
482    /// Optional blur policy. `None` means no blur signal — the
483    /// label semantics are entirely consumer-determined. Most
484    /// graduated-action labels do not specify blurs since they
485    /// represent account-level state, not content-level visual
486    /// treatment.
487    #[serde(default)]
488    pub blurs: Option<BlursToml>,
489    /// Optional localized display strings. Mirrors §6.4 `locales`
490    /// on label-value definitions; an emission-time policy can
491    /// declare them so a future #56-style trust-chain consumer
492    /// can present labels with operator-supplied display strings.
493    /// Empty by default.
494    #[serde(default)]
495    pub locales: Vec<LocaleToml>,
496}
497
498fn default_label_emission_enabled() -> bool {
499    true
500}
501fn default_emit_reason_labels() -> bool {
502    true
503}
504fn default_warning_emits_label() -> bool {
505    false
506}
507fn default_reason_label_prefix() -> String {
508    "reason-".to_string()
509}
510fn default_label_spec_severity() -> SeverityToml {
511    SeverityToml::Alert
512}
513
514/// TOML projection of [`crate::AdminConfig`]. Separate from the runtime
515/// type because (a) `AdminConfig` is constructed from a vector of owned
516/// strings and doesn't itself derive `Deserialize`, (b) keeping the
517/// wire shape here avoids coupling the server module to figment/serde.
518#[derive(Debug, Clone, Default, Deserialize)]
519pub struct AdminConfigToml {
520    /// Operator-declared label values. When `Some`, `applyLabel` only
521    /// accepts values in this set. When absent, any val ≤128 bytes is
522    /// accepted.
523    #[serde(default)]
524    pub label_values: Option<Vec<String>>,
525}
526
527/// Labeler policy (§F1, §6.4) — the content of the
528/// `app.bsky.labeler.service` record that `cairn publish-service-record`
529/// emits to the operator's PDS. Field-name mapping to the wire shape is
530/// via `#[serde(rename)]` at the runtime-side boundary in
531/// [`crate::service_record`]; TOML stays snake_case for operator ergonomics.
532#[derive(Debug, Clone, serde::Serialize, Deserialize)]
533pub struct LabelerConfigToml {
534    /// Short-name list of labels this instance will emit (§6.4
535    /// `policies.labelValues`). Every value here must also have a
536    /// matching definition in `label_value_definitions` unless it's a
537    /// global well-known value (§6.5). Publishing rejects if a non-
538    /// global identifier has no definition.
539    pub label_values: Vec<String>,
540    /// Per-label metadata entries (§6.4
541    /// `policies.labelValueDefinitions`). Empty vec is legal — means
542    /// only global values, no custom definitions.
543    #[serde(default)]
544    pub label_value_definitions: Vec<LabelValueDefinitionToml>,
545    /// Optional §6.4 `reasonTypes`. Typically the
546    /// `com.atproto.moderation.defs#reason*` set matching createReport
547    /// (§F11).
548    #[serde(default)]
549    pub reason_types: Vec<String>,
550    /// Optional §6.4 `subjectTypes` (e.g. `["account", "record"]`).
551    #[serde(default)]
552    pub subject_types: Vec<String>,
553    /// Optional §6.4 `subjectCollections` (e.g.
554    /// `["app.bsky.feed.post"]`).
555    #[serde(default)]
556    pub subject_collections: Vec<String>,
557}
558
559/// Single entry in `labelValueDefinitions`. §6.4 constraints:
560/// `severity` + `blurs` + `locales` required; `locales` must be
561/// non-empty; `default_setting` + `adult_only` optional.
562#[derive(Debug, Clone, serde::Serialize, Deserialize)]
563pub struct LabelValueDefinitionToml {
564    /// The label value this definition describes (matches an entry
565    /// in [`LabelerConfigToml::label_values`]).
566    pub identifier: String,
567    /// §6.4 severity — how consumers should weight this label.
568    pub severity: SeverityToml,
569    /// §6.4 blur policy — whether consumer UIs should obscure
570    /// content or media on a match.
571    pub blurs: BlursToml,
572    /// Optional §6.4 default consumer-side setting. Omit to let
573    /// consumers pick their own default.
574    #[serde(default)]
575    pub default_setting: Option<DefaultSettingToml>,
576    /// Optional §6.4 flag marking the label as 18+ only.
577    #[serde(default)]
578    pub adult_only: Option<bool>,
579    /// Non-empty list of localized display strings (§6.4 requires
580    /// ≥1 locale per definition).
581    pub locales: Vec<LocaleToml>,
582}
583
584/// §6.4 severity enum.
585#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize)]
586#[serde(rename_all = "lowercase")]
587pub enum SeverityToml {
588    /// Informational; consumers typically don't gate on this.
589    Inform,
590    /// Alert the viewer; consumers generally surface a warning.
591    Alert,
592    /// No severity signal.
593    None,
594}
595
596/// §6.4 blur policy — what consumer UIs should obscure on a match.
597#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, Deserialize)]
598#[serde(rename_all = "lowercase")]
599pub enum BlursToml {
600    /// Blur the post / record body.
601    Content,
602    /// Blur embedded media only.
603    Media,
604    /// No blurring.
605    None,
606}
607
608/// §6.4 default consumer-side setting.
609#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize)]
610#[serde(rename_all = "lowercase")]
611pub enum DefaultSettingToml {
612    /// Consumers default to showing the content with no treatment.
613    Ignore,
614    /// Consumers default to surfacing a warning.
615    Warn,
616    /// Consumers default to hiding the content.
617    Hide,
618}
619
620/// Localized display strings for a label value definition (§6.4).
621#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, Deserialize)]
622pub struct LocaleToml {
623    /// BCP-47 language tag (e.g. `"en"`, `"fr-CA"`).
624    pub lang: String,
625    /// Short display name shown in consumer UIs.
626    pub name: String,
627    /// Longer explanation, typically shown on tooltip / expand.
628    pub description: String,
629}
630
631/// TOML projection of [`crate::RetentionConfig`]. Field names match
632/// the runtime struct one-for-one; serde defaults mirror
633/// [`crate::RetentionConfig::default()`] so an absent `[retention]`
634/// block produces the §F4 default policy without operator opt-in.
635///
636/// `retention_days` is *not* on this struct — it lives under
637/// [`crate::SubscribeConfig`] and is consumed by both the read-side
638/// floor (`query_oldest_retained`) and the sweep cutoff. Splitting
639/// it would risk drift between the floor and the sweep window.
640#[derive(Debug, Clone, Deserialize)]
641pub struct RetentionConfigToml {
642    /// Master toggle for the scheduled sweep. Default `true`.
643    #[serde(default = "default_sweep_enabled")]
644    pub sweep_enabled: bool,
645    /// UTC hour-of-day (0..=23) for the scheduled sweep. Default 4.
646    /// Validated by [`Config::validate`].
647    #[serde(default = "default_sweep_run_at_utc_hour")]
648    pub sweep_run_at_utc_hour: u8,
649    /// Rows per DELETE transaction. Default 1000.
650    #[serde(default = "default_sweep_batch_size")]
651    pub sweep_batch_size: i64,
652}
653
654impl Default for RetentionConfigToml {
655    fn default() -> Self {
656        Self {
657            sweep_enabled: default_sweep_enabled(),
658            sweep_run_at_utc_hour: default_sweep_run_at_utc_hour(),
659            sweep_batch_size: default_sweep_batch_size(),
660        }
661    }
662}
663
664fn default_sweep_enabled() -> bool {
665    true
666}
667fn default_sweep_run_at_utc_hour() -> u8 {
668    4
669}
670fn default_sweep_batch_size() -> i64 {
671    1000
672}
673
674/// TOML projection of [`crate::policy::automation::PolicyAutomationPolicy`]
675/// (§F22, #71). The `enabled` field carries a serde default so a partial
676/// `[policy_automation]` declaration with only rules picks up the
677/// shipped default (`true`); operators wanting the engine fully off
678/// declare `enabled = false` explicitly.
679#[derive(Debug, Clone, Deserialize)]
680pub struct PolicyAutomationPolicyToml {
681    /// Master toggle. `true` (default) — engine evaluates rules on
682    /// every recordAction. `false` — engine skips evaluation
683    /// entirely; declared rules don't fire even if their thresholds
684    /// would match.
685    #[serde(default = "default_policy_automation_enabled")]
686    pub enabled: bool,
687    /// `[policy_automation.rules.<rule_name>]` sub-blocks. Map key
688    /// is the rule's identifier (validated at load time as
689    /// lowercase a-z / 0-9 / underscore, must start with a letter,
690    /// 1-64 chars). Empty map is the default — when
691    /// `[policy_automation]` is declared without any rules, the
692    /// engine is on but has nothing to fire.
693    #[serde(default)]
694    pub rules: BTreeMap<String, PolicyRuleToml>,
695}
696
697/// One rule entry under
698/// [`PolicyAutomationPolicyToml::rules`]. The runtime equivalent
699/// (with parsed action_type, validated mode, parsed duration,
700/// resolved reason_codes) is
701/// [`crate::policy::automation::PolicyRule`].
702#[derive(Debug, Clone, Deserialize)]
703pub struct PolicyRuleToml {
704    /// Strike count crossing point that triggers the rule.
705    /// Validated `> 0` at config-load time. Rule fires only on
706    /// crossing — pre-action count below threshold AND post-action
707    /// count at or above. See [`crate::policy::automation`] module
708    /// docs for the full semantic.
709    pub threshold_strikes: i64,
710    /// Action type the rule produces when it fires. One of
711    /// `warning` / `note` / `temp_suspension` / `indef_suspension`
712    /// / `takedown`. Validated via
713    /// [`crate::moderation::types::ActionType::from_db_str`].
714    pub action_type: String,
715    /// `auto` (cairn-mod records the action directly inside the
716    /// triggering recordAction transaction) or `flag` (cairn-mod
717    /// records a `pending_policy_actions` row awaiting moderator
718    /// review). Case-sensitive at parse.
719    pub mode: String,
720    /// ISO-8601 duration string (e.g. `"P3D"`). Required iff
721    /// `action_type == "temp_suspension"`; rejected for other
722    /// types. Same parser as v1.4 #51's `duration_iso` surface
723    /// (no Y/M support — see `crate::writer::parse_iso8601_duration`,
724    /// `pub(crate)` for cross-module reuse).
725    pub duration: Option<String>,
726    /// Reason codes attached to the action this rule produces.
727    /// Each entry must exist in the operator's
728    /// `[moderation_reasons]` vocabulary; cross-validation lives
729    /// at [`Config::validate`]. When omitted, the resolver
730    /// substitutes a single-element `["policy-threshold"]` — that
731    /// identifier must therefore appear in the vocabulary, or the
732    /// operator must specify `reason_codes` explicitly on every
733    /// rule.
734    #[serde(default)]
735    pub reason_codes: Option<Vec<String>>,
736}
737
738fn default_policy_automation_enabled() -> bool {
739    true
740}
741
742/// Operator-side PDS auth config (§F1). Scope is narrow — just the
743/// PDS URL + session file path. Named `[operator]` today; if future
744/// operator-identity fields land (contact, alerts, etc.) the table
745/// stays small enough to nest those in, or split to `[operator.pds]`
746/// then.
747#[derive(Debug, Clone, Deserialize)]
748pub struct OperatorConfigToml {
749    /// PDS base URL (e.g. `https://bsky.social`). No default — the
750    /// labeler owner's PDS varies per deployment.
751    pub pds_url: String,
752    /// On-disk path for the operator session file. Written by
753    /// `cairn operator-login`, read by `cairn publish-service-record`.
754    /// Same §5.3 invariants as the moderator session file (mode 0600,
755    /// owned by running user) via the shared
756    /// `crate::credential_file` helper.
757    pub session_path: std::path::PathBuf,
758}
759
760fn default_bind_addr() -> SocketAddr {
761    DEFAULT_BIND_ADDR
762        .parse()
763        .expect("DEFAULT_BIND_ADDR is a valid socket address")
764}
765
766impl Config {
767    /// Post-load validation run by [`Config::load`]. Exposed so call
768    /// sites that construct a `Config` directly (e.g., tests) can
769    /// share the same rule set.
770    pub fn validate(&self) -> Result<()> {
771        url::Url::parse(&self.service_endpoint).map_err(|e| {
772            crate::error::Error::Signing(format!("config.service_endpoint is not a valid URL: {e}"))
773        })?;
774        if self.retention.sweep_run_at_utc_hour >= 24 {
775            return Err(crate::error::Error::Signing(format!(
776                "config.retention.sweep_run_at_utc_hour={} is out of range (0..=23)",
777                self.retention.sweep_run_at_utc_hour
778            )));
779        }
780        if self.retention.sweep_batch_size <= 0 {
781            return Err(crate::error::Error::Signing(format!(
782                "config.retention.sweep_batch_size={} must be > 0",
783                self.retention.sweep_batch_size
784            )));
785        }
786        // Reason vocabulary (§F20, #47): build via the canonical
787        // resolver and bind it for the §F22 #71 cross-check below.
788        // If the operator declared an empty [moderation_reasons]
789        // section, an invalid identifier, a zero base_weight, or
790        // an empty description, the resolver returns an
791        // Error::Signing here that surfaces as a config-load
792        // failure at startup.
793        let vocab = crate::moderation::reasons::ReasonVocabulary::from_config(self)?;
794        // Strike policy (§F20, #48): same single-source-of-truth
795        // pattern. The resolver applies the curve-length convention
796        // (`max(0, threshold - 1)`), strict-ascending check, and
797        // decay-window-days >= 1 check. See
798        // [`crate::moderation::policy`] for the full validation
799        // rules.
800        let _ = crate::moderation::policy::StrikePolicy::from_config(self)?;
801        // Label emission policy (§F21, #58): same single-source-of-
802        // truth pattern. The resolver applies the label-value naming
803        // conventions, no-collision check across action_label_overrides
804        // entries, and valid-action_type-key check on the override
805        // maps. See [`crate::labels::policy`] for the full validation
806        // rules.
807        let _ = crate::labels::policy::LabelEmissionPolicy::from_config(self)?;
808        // Policy automation (§F22, #71): each rule's per-field
809        // validation runs in from_config; the cross-block check —
810        // every rule's reason_codes must exist in the operator's
811        // [moderation_reasons] vocabulary — runs here, where both
812        // resolvers have completed. Each loader stays focused on
813        // its own block; cross-block validation lives at the
814        // Config level.
815        let policy_automation =
816            crate::policy::automation::PolicyAutomationPolicy::from_config(self)?;
817        policy_automation.validate_reason_codes_against(&vocab)?;
818        // PDS-admin policy (§F23, #83, v1.7): per-block validation
819        // (URL scheme, env-var resolution, allowed-method set,
820        // with_lift_after gating, every-action-type-mapped check)
821        // runs in from_config. No cross-block check beyond
822        // ActionType::from_db_str validation in the resolver
823        // itself; the bridge's destination is the PDS, not
824        // cairn-mod's reason vocabulary, so [moderation_reasons]
825        // is unrelated. See [`crate::pds_admin::PdsAdminPolicy`]
826        // for the full validation rules.
827        let _ = crate::pds_admin::PdsAdminPolicy::from_config(self)?;
828        // [xrpc_gateway] (§F23 inbound, #91, v1.7). Run the
829        // resolver for its side effect: enforces required-field
830        // presence when enabled, malformed-DID rejection,
831        // bounds-checking on numeric tunables, unknown-extras-key
832        // rejection. The resolved config is rebuilt at
833        // `serve::run` startup; here we only care that the
834        // validation passes.
835        let _ = crate::xrpc_gateway::XrpcGatewayConfig::from_config(self)?;
836        // Path existence of db_path / signing_key_path is checked at
837        // use time by storage::open and SigningKey::load_from_file —
838        // duplicating here would just double-fail and lose the
839        // specific cause.
840        Ok(())
841    }
842
843    /// Load configuration from the default TOML location + env
844    /// overrides (see [`Self::load_from`] for the full precedence
845    /// rules). The default location is `CAIRN_CONFIG` env var, or
846    /// `/etc/cairn/cairn.toml` if unset.
847    pub fn load() -> Result<Self> {
848        let toml_path: PathBuf = std::env::var_os("CAIRN_CONFIG")
849            .map(PathBuf::from)
850            .unwrap_or_else(|| PathBuf::from("/etc/cairn/cairn.toml"));
851        Self::load_from(Some(&toml_path))
852    }
853
854    /// Load configuration with an explicit TOML path (or `None` to
855    /// skip the file layer entirely and rely on env overrides).
856    ///
857    /// Sources, low to high precedence:
858    /// 1. Compiled-in defaults (`bind_addr` if unset, empty admin
859    ///    table).
860    /// 2. `toml_path` if `Some` and the file exists.
861    /// 3. Environment variables prefixed `CAIRN_`
862    ///    (e.g. `CAIRN_SERVICE_DID`).
863    ///
864    /// `cairn serve --config <path>` routes through this without
865    /// mutating process env (which is `unsafe` under Rust 2024 and
866    /// blocked by the crate's `#![forbid(unsafe_code)]`).
867    pub fn load_from(toml_path: Option<&std::path::Path>) -> Result<Self> {
868        let mut fig = Figment::new();
869        if let Some(p) = toml_path
870            && p.is_file()
871        {
872            fig = fig.merge(Toml::file(p));
873        }
874        fig = fig.merge(Env::prefixed("CAIRN_"));
875
876        let cfg: Config = fig.extract()?;
877        cfg.validate()?;
878        Ok(cfg)
879    }
880}
881
882impl From<AdminConfigToml> for crate::AdminConfig {
883    fn from(t: AdminConfigToml) -> Self {
884        // service_did / service_endpoint / declared_label_values are
885        // populated separately at admin_router-construction time
886        // (see `serve::run`) — they live elsewhere in `Config` and
887        // would needlessly couple [admin] to those fields if pulled
888        // through here.
889        crate::AdminConfig {
890            label_values: t.label_values,
891            ..Default::default()
892        }
893    }
894}
895
896impl From<RetentionConfigToml> for crate::RetentionConfig {
897    fn from(t: RetentionConfigToml) -> Self {
898        crate::RetentionConfig {
899            sweep_enabled: t.sweep_enabled,
900            sweep_run_at_utc_hour: t.sweep_run_at_utc_hour,
901            sweep_batch_size: t.sweep_batch_size,
902        }
903    }
904}