Skip to main content

cairn_mod/
writer.rs

1//! Single-writer task (§F5 / §10 architecture).
2//!
3//! All mutations to `labels`, `label_sequence`, `audit_log`, and
4//! `server_instance_lease` flow through one task that owns the write pool.
5//! Other components obtain a cheaply-cloneable [`WriterHandle`] and submit
6//! [`ApplyLabelRequest`] / [`NegateLabelRequest`] over an mpsc channel;
7//! replies travel on per-request oneshot channels so the caller may cancel
8//! (drop the future) without corrupting writer state — the transaction
9//! still commits, the reply is discarded.
10//!
11//! Invariants this module enforces, any of which is load-bearing:
12//!
13//! - **Single instance.** On startup [`spawn`] either acquires the lease
14//!   row in `server_instance_lease` (id = 1) or returns
15//!   [`Error::LeaseHeld`]. A background heartbeat updates `last_heartbeat`
16//!   every 10s; a second Cairn started against the same file sees a
17//!   <60s-old heartbeat and refuses. The 60s threshold is 6× the heartbeat
18//!   interval — tolerant of a single missed tick, strict enough that a
19//!   legitimately-orphaned lease (hard crash, unclean shutdown) ages out
20//!   before the operator manually restarts.
21//!
22//! - **Monotonic seq.** Sequence values come from
23//!   `label_sequence.seq` (AUTOINCREMENT) reserved inside the same
24//!   transaction as the `labels` INSERT. Under AUTOINCREMENT, SQLite
25//!   never reuses a seq value even across rollbacks.
26//!
27//! - **Monotonic cts.** For each `(src, uri, val)` tuple the writer
28//!   clamps to `max(wall_now, prev_cts + 1ms)` (§6.1) using the
29//!   `labels_tuple_idx` composite index for the prev-cts lookup.
30//!
31//! - **Audit-per-write.** Every successful label INSERT produces exactly
32//!   one `audit_log` row **in the same transaction**. The audit row's
33//!   `reason` column carries a JSON object documenting the event
34//!   ({"val", "neg", "moderator_reason"}). See [`AUDIT_REASON_SCHEMA`].
35//!
36//! - **Sign-then-insert.** `sign_label` runs between sequence reservation
37//!   and the labels INSERT. The signed bytes include `ver` but not `seq`
38//!   or `signing_key_id` (§6.2 step 1, §6.1: seq lives on the frame, not
39//!   the label).
40
41use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
42
43use proto_blue_crypto::{K256Keypair, Keypair as _, format_multikey};
44use sqlx::{Pool, Sqlite};
45use time::format_description::FormatItem;
46use time::macros::format_description;
47use time::{OffsetDateTime, PrimitiveDateTime};
48use tokio::sync::{broadcast, mpsc, oneshot, watch};
49use tokio::time::{MissedTickBehavior, interval};
50use uuid::Uuid;
51
52use crate::error::{Error, Result};
53use crate::label::Label;
54use crate::moderation::decay::calculate_strike_state;
55use crate::moderation::policy::StrikePolicy;
56use crate::moderation::reasons::ReasonVocabulary;
57use crate::moderation::strike::{
58    StrikeApplication, calculate as strike_calculate, resolve_primary_reason,
59};
60use crate::moderation::types::{ActionRecord, ActionType};
61use crate::moderation::window::compute_position_in_window;
62use crate::server::RetentionConfig;
63use crate::signing::sign_label;
64use crate::signing_key::SigningKey;
65
66/// Lease freshness threshold (§F5). A lease younger than this is held by
67/// a live peer; younger than 10s would be flaky under a single missed
68/// heartbeat, older than ~2× the value risks a genuine zombie blocking a
69/// legitimate restart for too long.
70pub(crate) const LEASE_STALE_MS: i64 = 60_000;
71
72/// Heartbeat interval. 10s × 6 = 60s staleness budget per the threshold.
73const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
74
75/// Granularity at which the writer checks "is it time to start the
76/// scheduled sweep?". Daily fires bucketed by UTC hour means we don't
77/// need finer than this; 60s keeps the check cost negligible (one
78/// `Instant::now()` comparison + an `Option` peek per minute).
79const SWEEP_CHECK_INTERVAL: Duration = Duration::from_secs(60);
80
81/// Upper bound on queued write commands. Moderators rarely drive more
82/// than single-digit req/s even on busy operators; 64 is a comfortable
83/// overshoot that still makes backpressure visible quickly under a bug.
84const COMMAND_BUFFER: usize = 64;
85
86/// Broadcast buffer for [`LabelEvent`] fan-out. Slow subscribers hit
87/// `RecvError::Lagged` past this; the subscribeLabels endpoint (#7) turns
88/// that into a client-connection close.
89const BROADCAST_BUFFER: usize = 1024;
90
91/// Audit-log `reason` JSON schema for `label_applied` / `label_negated`.
92/// Centralized as a doc constant so future actions (signing_key_added,
93/// report_resolved, etc.) have an obvious place to register their shape.
94///
95/// ```json
96/// {
97///   "val": "<label value>",
98///   "neg": true | false,
99///   "moderator_reason": "<free text>" | null
100/// }
101/// ```
102#[doc(alias = "audit_log.reason")]
103pub const AUDIT_REASON_SCHEMA: &str =
104    "label_applied / label_negated: { val, neg, moderator_reason }";
105
106/// Audit-log `reason` JSON schema for `report_resolved` (§F12 resolveReport
107/// atomicity — two rows land in one transaction: the inner label_applied
108/// per [`AUDIT_REASON_SCHEMA`] plus this one).
109///
110/// ```json
111/// {
112///   "applied_label_val": "<val>" | null,
113///   "resolution_reason": "<free text>" | null
114/// }
115/// ```
116#[doc(alias = "audit_log.reason.report_resolved")]
117pub const AUDIT_REASON_RESOLVE_REPORT: &str =
118    "report_resolved: { applied_label_val, resolution_reason }";
119
120/// Audit-log `reason` JSON schema for `reporter_flagged` /
121/// `reporter_unflagged` (§F12 flagReporter).
122///
123/// ```json
124/// {
125///   "did": "<flagged DID>",
126///   "suppressed": true | false,
127///   "moderator_reason": "<free text>" | null
128/// }
129/// ```
130#[doc(alias = "audit_log.reason.flag_reporter")]
131pub const AUDIT_REASON_FLAG_REPORTER: &str =
132    "reporter_flagged / reporter_unflagged: { did, suppressed, moderator_reason }";
133
134/// Audit-log `reason` JSON schema for `retention_sweep` (§F4 — written
135/// only by the operator-initiated admin path; the scheduled-fire
136/// path does NOT audit per Q6/D2). Captures the result of the sweep
137/// run for reconstruction-friendly ops queries.
138///
139/// ```json
140/// {
141///   "rows_deleted": <i64>,
142///   "batches": <u64>,
143///   "duration_ms": <u64>,
144///   "retention_days_applied": <u32> | null
145/// }
146/// ```
147#[doc(alias = "audit_log.reason.retention_sweep")]
148pub const AUDIT_REASON_RETENTION_SWEEP: &str =
149    "retention_sweep: { rows_deleted, batches, duration_ms, retention_days_applied }";
150
151/// Closed set of `audit_log.action` values emitted by Cairn write paths.
152///
153/// §F10: audit rows only for moderation decisions, not for input/operational
154/// events — `createReport` (input) intentionally does NOT audit. The
155/// listAuditLog handler validates its `action` query param against this
156/// exact set; unknown values return `InvalidRequest` rather than silently
157/// matching zero rows.
158///
159/// Adding a new moderation action requires: (1) the handler writes an
160/// `INSERT INTO audit_log (action, ...)` row, (2) the value is added here,
161/// (3) the `reason` schema for the new action is documented as a const
162/// alongside [`AUDIT_REASON_SCHEMA`] and friends.
163pub const AUDIT_ACTION_VALUES: &[&str] = &[
164    "label_applied",
165    "label_negated",
166    "report_resolved",
167    "reporter_flagged",
168    "reporter_unflagged",
169    "retention_sweep",
170    "subject_action_recorded",
171    "subject_action_revoked",
172];
173
174/// Closed set of `audit_log.outcome` values matching the SQL `CHECK`
175/// constraint in `migrations/0001_init.sql`. listAuditLog validates the
176/// `outcome` query param against this set; the SQL constraint itself is
177/// the durable source of truth — this slice mirrors it so the handler
178/// can reject invalid values pre-query without round-tripping to SQLite.
179pub const AUDIT_OUTCOME_VALUES: &[&str] = &["success", "failure"];
180
181/// RFC-3339 with millisecond precision. `Z` is appended by
182/// [`rfc3339_from_epoch_ms`] and stripped by [`parse_rfc3339_ms`] — kept
183/// out of the format description because `time::OffsetDateTime::parse`
184/// can't infer a UTC offset from the literal character `Z`, and
185/// symmetric Z-handling at the string boundary is simpler than switching
186/// parsing-only to `well_known::Rfc3339`.
187const CTS_FORMAT: &[FormatItem<'_>] =
188    format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]");
189
190/// Moderator request to apply a label (positive event).
191#[derive(Debug, Clone)]
192pub struct ApplyLabelRequest {
193    /// The DID of the moderator issuing the request. Becomes
194    /// `audit_log.actor_did`. `src` on the label itself is the writer's
195    /// service DID, not this.
196    pub actor_did: String,
197    /// AT-URI or DID of the subject being labeled.
198    pub uri: String,
199    /// Optional record-version pin. Present means "this specific version";
200    /// absent means "all versions / the account" per §6.1.
201    pub cid: Option<String>,
202    /// Label value, ≤128 bytes (validated on insert by schema CHECK and
203    /// by caller-side input validation on the XRPC boundary).
204    pub val: String,
205    /// Optional expiration timestamp (RFC-3339 Z). Stored only; expiry
206    /// enforcement is v1.1 (§F7).
207    pub exp: Option<String>,
208    /// Free-text reason recorded to `audit_log` only. Not signed, not
209    /// included in the label record.
210    pub moderator_reason: Option<String>,
211}
212
213/// Moderator request to negate (withdraw) a previously-applied label.
214///
215/// Uniqueness is at the `(src, uri, val)` tuple. The negation copies the
216/// most-recent applied event's `cid` so the negation pins the same record
217/// version — callers don't supply it.
218#[derive(Debug, Clone)]
219pub struct NegateLabelRequest {
220    /// Moderator DID issuing the negation. Becomes
221    /// `audit_log.actor_did`.
222    pub actor_did: String,
223    /// AT-URI or DID of the subject whose label is being withdrawn.
224    /// The `cid` pinning (if any) is copied from the prior apply.
225    pub uri: String,
226    /// Label value being negated. Must match an existing applied
227    /// label on `(src, uri, val)` or the call errors with
228    /// `LabelNotFound`.
229    pub val: String,
230    /// Free-text reason recorded to `audit_log` only.
231    pub moderator_reason: Option<String>,
232}
233
234/// A committed label event. Returned by `apply_label` / `negate_label` and
235/// broadcast to subscribers (subscribeLabels consumers in #7). Wire
236/// serialization is that consumer's concern — the LabelEvent itself
237/// carries no serde derive because the canonical encoding for the wire is
238/// the DAG-CBOR path in `crate::signing`, not serde JSON.
239#[derive(Debug, Clone)]
240pub struct LabelEvent {
241    /// Frame sequence number from `label_sequence`. Strictly monotonic.
242    pub seq: i64,
243    /// The full signed label (`sig` populated).
244    pub label: Label,
245}
246
247/// Internal write command. One variant per public method.
248enum WriteCommand {
249    Apply(ApplyLabelRequest, oneshot::Sender<Result<LabelEvent>>),
250    Negate(NegateLabelRequest, oneshot::Sender<Result<LabelEvent>>),
251    ResolveReport(
252        ResolveReportRequest,
253        oneshot::Sender<Result<ResolvedReport>>,
254    ),
255    Sweep(SweepRequest, oneshot::Sender<Result<SweepBatchResult>>),
256    /// Append a hash-chained audit row (#39). Used by in-process
257    /// callers that don't have an existing transaction (e.g.,
258    /// `retentionSweep` after the sweep itself completes). Callers
259    /// that already hold a transaction (writer-internal handlers,
260    /// `flag_reporter`) call [`crate::audit::append::append_in_tx`]
261    /// directly within that transaction instead.
262    AppendAudit(
263        crate::audit::append::AuditRowForAppend,
264        oneshot::Sender<Result<i64>>,
265    ),
266    /// Record a graduated-action subject_actions row (§F20 / #51).
267    /// Single-transaction: input validation, strike calculation,
268    /// subject_actions INSERT, strike_state UPSERT, audit_log
269    /// hash-chain append.
270    RecordAction(RecordActionRequest, oneshot::Sender<Result<RecordedAction>>),
271    /// Revoke a previously-recorded subject_actions row (§F20 /
272    /// #51). Single-transaction: row lookup + state-check,
273    /// revoked_at UPDATE, strike_state recompute, audit_log
274    /// hash-chain append.
275    RevokeAction(RevokeActionRequest, oneshot::Sender<Result<RevokedAction>>),
276    Shutdown(oneshot::Sender<Result<()>>),
277}
278
279/// Inline label-application sub-object for [`ResolveReportRequest`].
280/// Structurally aligned with [`ApplyLabelRequest`] minus the
281/// moderator-reason (that field on the outer request already captures
282/// the resolution rationale; the label's own audit reason is derived
283/// from the resolution flow).
284#[derive(Debug, Clone)]
285pub struct ApplyLabelInline {
286    /// Subject the label applies to (AT-URI or DID).
287    pub uri: String,
288    /// Optional record-version pin; `None` targets the account / all
289    /// versions of the record.
290    pub cid: Option<String>,
291    /// Label value to apply (≤128 bytes).
292    pub val: String,
293    /// Optional expiration timestamp (RFC-3339 Z). Stored only;
294    /// enforcement is v1.1.
295    pub exp: Option<String>,
296}
297
298/// Resolution outcome (#27): the implicit "with-label vs without-label"
299/// semantic, made explicit. The wire shape is `applyLabel: Option<…>`
300/// per the `resolveReport` lexicon; this enum is internal and the
301/// handler maps `None → Dismiss` / `Some(_) → ApplyLabel(_)` at the
302/// HTTP boundary.
303#[derive(Debug, Clone)]
304pub enum ResolutionAction {
305    /// Resolve without emitting a label (the operator-UX "dismiss"
306    /// flow). `resolution_label` on the row stays NULL.
307    Dismiss,
308    /// Resolve and emit a label in the same transaction. The label's
309    /// `val` is also recorded as `resolution_label` on the report row.
310    ApplyLabel(ApplyLabelInline),
311}
312
313impl ResolutionAction {
314    /// Borrow the inner `ApplyLabelInline` if this is an `ApplyLabel`
315    /// variant. Convenience for the writer's UPDATE / audit code that
316    /// needs both the optional label *and* its `val` projection.
317    pub fn as_apply(&self) -> Option<&ApplyLabelInline> {
318        match self {
319            ResolutionAction::Dismiss => None,
320            ResolutionAction::ApplyLabel(a) => Some(a),
321        }
322    }
323}
324
325/// Request to resolve a report (§F12 `resolveReport`). The optional
326/// label is applied **in the same transaction** as the report status
327/// update and both audit rows — §F5 single-writer invariant plus §F12
328/// atomicity requirement documented in the `resolveReport` lexicon.
329#[derive(Debug, Clone)]
330pub struct ResolveReportRequest {
331    /// Moderator DID issuing the resolution. Becomes
332    /// `audit_log.actor_did` on both the label-applied (if any) and
333    /// report_resolved rows.
334    pub actor_did: String,
335    /// Primary key of the report being resolved.
336    pub report_id: i64,
337    /// Whether the resolution emits a label or just closes the report.
338    /// Replaces the pre-#27 `apply_label: Option<ApplyLabelInline>`
339    /// representation; semantically identical, named for the operator
340    /// UX (dismiss vs apply-label).
341    pub action: ResolutionAction,
342    /// Free-text resolution rationale recorded to audit_log.
343    pub resolution_reason: Option<String>,
344}
345
346/// Result of a successful resolve. `label_event` is `Some(..)` iff
347/// the request carried `apply_label`; the broadcast has already
348/// happened inside the writer task post-commit.
349#[derive(Debug, Clone)]
350pub struct ResolvedReport {
351    /// The updated report row (status now `resolved`).
352    pub report: crate::report::Report,
353    /// The emitted label event, if the resolution included an
354    /// `apply_label`. Signed, broadcast, and committed as part of
355    /// the same transaction as the report UPDATE.
356    pub label_event: Option<LabelEvent>,
357}
358
359/// Trigger for the retention sweep (§F4). Carries no per-call
360/// parameters today — the cutoff comes from `retention_days` baked
361/// into the writer at spawn time, not from the request — but is
362/// kept as a typed unit so a future "sweep with explicit override
363/// for this one run" remains a non-breaking change to the variant.
364#[derive(Debug, Clone, Default)]
365pub struct SweepRequest;
366
367/// Per-batch outcome from one sweep dispatch through the writer's
368/// internal `WriteCommand::Sweep` channel. The writer task processes
369/// ONE batch per command so its main `select!` can interleave
370/// incoming label writes between batches (§F4 + §F5 — single-writer
371/// invariant + bounded latency). Callers who want a full sweep loop
372/// until [`Self::has_more`] is `false`; the [`WriterHandle::sweep`]
373/// convenience wrapper does this internally.
374#[derive(Debug, Clone)]
375pub struct SweepBatchResult {
376    /// Rows deleted in this batch. Zero means "no more old rows
377    /// match the cutoff" — caller stops looping.
378    pub rows_deleted: i64,
379    /// `true` when the batch hit the configured `sweep_batch_size`
380    /// limit and a follow-up batch may find more rows. `false`
381    /// indicates the batch was partial (last batch) and the sweep
382    /// is complete.
383    pub has_more: bool,
384    /// Cutoff days actually applied. `None` when the writer was
385    /// spawned with `retention_days = None` — the sweep is a no-op
386    /// in that configuration and `rows_deleted` is always 0.
387    pub retention_days_applied: Option<u32>,
388}
389
390/// Request to record a new subject_actions row (§F20 / #51 graduated-
391/// action moderation). The handler validates the inputs, computes
392/// the strike value at action time via the v1.4 calculators
393/// (#48/#49/#50/#51), inserts the row, updates the strike-state
394/// cache, and writes a hash-chained audit_log row — all in a
395/// single transaction per §F5 atomicity.
396#[derive(Debug, Clone)]
397pub struct RecordActionRequest {
398    /// Raw subject — DID (`did:plc:...`, `did:web:...`) or AT-URI
399    /// (`at://did:.../col/r`). The handler routes to subject_did vs
400    /// subject_uri based on prefix; for AT-URIs the parent repo DID
401    /// is extracted as subject_did and strike accounting rolls up
402    /// to the account.
403    pub subject: String,
404    /// DID of the moderator/admin recording the action (JWT iss
405    /// from XRPC; CLI caller DID otherwise). Becomes
406    /// subject_actions.actor_did and audit_log.actor_did.
407    pub actor_did: String,
408    /// Graduated-action category. The handler enforces:
409    /// `temp_suspension` requires `duration_iso`; everything else
410    /// rejects it.
411    pub action_type: ActionType,
412    /// Operator-vocabulary identifiers from `[moderation_reasons]`.
413    /// Must be non-empty. Multi-reason resolution: severe wins; else
414    /// highest base_weight wins; ties → first-listed.
415    pub reason_codes: Vec<String>,
416    /// ISO-8601 duration string (e.g. `P7D`). Required for
417    /// `temp_suspension`; rejected for other types. Stored verbatim
418    /// on the row for display; `expires_at` is the canonical
419    /// "when does it end" surface.
420    pub duration_iso: Option<String>,
421    /// Optional moderator-facing rationale.
422    pub notes: Option<String>,
423    /// Optional report row ids that motivated this action.
424    pub report_ids: Vec<i64>,
425}
426
427/// Result of a successful [`WriterHandle::record_action`]. The
428/// admin handler echoes these fields verbatim in the
429/// `tools.cairn.admin.recordAction` response; the CLI surfaces them
430/// in human and JSON output.
431#[derive(Debug, Clone)]
432pub struct RecordedAction {
433    /// Inserted subject_actions row id.
434    pub action_id: i64,
435    /// Reason's `base_weight` before dampening. `0` for warning/note.
436    pub strike_value_base: u32,
437    /// Strike weight actually applied after dampening.
438    pub strike_value_applied: u32,
439    /// `true` iff the dampening curve was consulted (see #49).
440    pub was_dampened: bool,
441    /// Subject's `current_strike_count` BEFORE this action — frozen
442    /// for forensic history.
443    pub strikes_at_time_of_action: u32,
444}
445
446/// Request to revoke a previously-recorded subject_actions row
447/// (§F20 / #51). Sets the row's revoked_at/revoked_by_did/
448/// revoked_reason columns (the schema's no-update-except-revoke
449/// trigger permits exactly this NULL→non-NULL transition);
450/// recomputes the strike-state cache; writes a hash-chained
451/// audit_log row. All in a single transaction.
452#[derive(Debug, Clone)]
453pub struct RevokeActionRequest {
454    /// subject_actions.id to revoke. Errors with
455    /// [`Error::ActionNotFound`] when the row doesn't exist;
456    /// [`Error::ActionAlreadyRevoked`] when revoked_at is already
457    /// non-NULL.
458    pub action_id: i64,
459    /// DID of the moderator/admin performing the revocation.
460    pub revoked_by_did: String,
461    /// Optional rationale stored on the row's revoked_reason column.
462    pub revoked_reason: Option<String>,
463}
464
465/// Result of a successful [`WriterHandle::revoke_action`].
466#[derive(Debug, Clone)]
467pub struct RevokedAction {
468    /// The revoked row's id (echoed for confirmation).
469    pub action_id: i64,
470    /// Wall-clock the revocation took effect, as RFC-3339 Z
471    /// (matches the [`AUDIT_REASON_RECORD_ACTION`] surface for
472    /// consumer convenience).
473    pub revoked_at: String,
474}
475
476/// Aggregate result of a full sweep run (returned by
477/// [`WriterHandle::sweep`] after looping over batches).
478#[derive(Debug, Clone)]
479pub struct SweepResult {
480    /// Total rows deleted across all batches.
481    pub rows_deleted: i64,
482    /// Number of batches issued.
483    pub batches: u64,
484    /// Wall-clock duration of the full sweep, in milliseconds.
485    pub duration_ms: u64,
486    /// Cutoff days actually applied, or `None` when the writer's
487    /// `retention_days` is `None` (sweep is a no-op).
488    pub retention_days_applied: Option<u32>,
489}
490
491/// Cheap handle to the writer task. Clones share the same underlying
492/// mpsc channel; a drop of the last clone is a silent shutdown signal
493/// to the writer task (receiver closes). For a clean shutdown that
494/// releases the lease row, call [`WriterHandle::shutdown`].
495#[derive(Debug, Clone)]
496pub struct WriterHandle {
497    tx: mpsc::Sender<WriteCommand>,
498    broadcast_tx: broadcast::Sender<LabelEvent>,
499    /// Flipped to `true` when the writer task starts its shutdown path.
500    /// Exposed via [`WriterHandle::shutdown_signal`] so peer components
501    /// (the subscribeLabels handler, future maintenance tasks) can close
502    /// their own resources cleanly. Using a watch channel rather than the
503    /// broadcast-channel close signal because clones of `WriterHandle`
504    /// keep `broadcast_tx` alive — receivers would otherwise never see
505    /// `RecvError::Closed`.
506    shutdown_rx: watch::Receiver<bool>,
507}
508
509impl WriterHandle {
510    /// Submit an apply-label request. Resolves when the writer has
511    /// committed the transaction and broadcast the event, or returns an
512    /// `Err` describing why the write was rejected.
513    pub async fn apply_label(&self, req: ApplyLabelRequest) -> Result<LabelEvent> {
514        let (reply_tx, reply_rx) = oneshot::channel();
515        self.tx
516            .send(WriteCommand::Apply(req, reply_tx))
517            .await
518            .map_err(|_| Error::Signing("writer task is shut down".into()))?;
519        reply_rx
520            .await
521            .map_err(|_| Error::Signing("writer dropped reply channel".into()))?
522    }
523
524    /// Submit a negate-label request. Returns [`Error::LabelNotFound`] if
525    /// no applied label currently exists for `(service_did, uri, val)`.
526    pub async fn negate_label(&self, req: NegateLabelRequest) -> Result<LabelEvent> {
527        let (reply_tx, reply_rx) = oneshot::channel();
528        self.tx
529            .send(WriteCommand::Negate(req, reply_tx))
530            .await
531            .map_err(|_| Error::Signing("writer task is shut down".into()))?;
532        reply_rx
533            .await
534            .map_err(|_| Error::Signing("writer dropped reply channel".into()))?
535    }
536
537    /// Resolve a report, optionally applying a label in the same
538    /// atomic transaction. The label INSERT + audit row + report
539    /// UPDATE + resolution audit row all commit together or not at
540    /// all; the label broadcast fires post-commit inside the writer
541    /// task (§F5 + §F12 atomicity contract).
542    ///
543    /// Errors:
544    /// - [`Error::ReportNotFound`] — `report_id` doesn't exist.
545    /// - [`Error::ReportAlreadyResolved`] — report is not in the
546    ///   `pending` state. Handler maps to a generic `InvalidRequest`.
547    pub async fn resolve_report(&self, req: ResolveReportRequest) -> Result<ResolvedReport> {
548        let (reply_tx, reply_rx) = oneshot::channel();
549        self.tx
550            .send(WriteCommand::ResolveReport(req, reply_tx))
551            .await
552            .map_err(|_| Error::Signing("writer task is shut down".into()))?;
553        reply_rx
554            .await
555            .map_err(|_| Error::Signing("writer dropped reply channel".into()))?
556    }
557
558    /// Trigger a §F4 retention sweep through the writer task. Loops
559    /// over single-batch dispatches until a batch returns
560    /// `has_more = false`, aggregating rows + duration.
561    /// Other writer commands interleave between batches (single-writer
562    /// invariant + bounded latency).
563    ///
564    /// Returns `rows_deleted = 0, batches = 0` when the writer was
565    /// spawned with `retention_days = None` (sweep configured off):
566    /// the first batch returns immediately and the loop exits with
567    /// the no-op result.
568    pub async fn sweep(&self, _req: SweepRequest) -> Result<SweepResult> {
569        let start = std::time::Instant::now();
570        let mut total_rows: i64 = 0;
571        let mut batches: u64 = 0;
572
573        loop {
574            let (reply_tx, reply_rx) = oneshot::channel();
575            self.tx
576                .send(WriteCommand::Sweep(SweepRequest, reply_tx))
577                .await
578                .map_err(|_| Error::Signing("writer task is shut down".into()))?;
579            let batch = reply_rx
580                .await
581                .map_err(|_| Error::Signing("writer dropped reply channel".into()))??;
582
583            total_rows += batch.rows_deleted;
584            // Count the no-op early-exit batch too — it represents
585            // the round-trip the caller paid for. Useful for tracing.
586            batches += 1;
587
588            if !batch.has_more {
589                return Ok(SweepResult {
590                    rows_deleted: total_rows,
591                    batches,
592                    duration_ms: start.elapsed().as_millis() as u64,
593                    retention_days_applied: batch.retention_days_applied,
594                });
595            }
596        }
597    }
598
599    /// Record a graduated-action moderation event (§F20 / #51).
600    /// The writer validates inputs, computes the strike value at
601    /// action time via the v1.4 calculators (#48/#49/#50/#51),
602    /// inserts the subject_actions row, updates the strike-state
603    /// cache, and writes a hash-chained audit_log row — all in a
604    /// single transaction. Errors:
605    ///
606    /// - [`Error::ReasonNotFound`] when a reason identifier is not
607    ///   declared in `[moderation_reasons]`.
608    /// - [`Error::DurationRequiredForTempSuspension`] for
609    ///   `temp_suspension` without `duration_iso`.
610    /// - [`Error::DurationOnlyForTempSuspension`] for non-temp
611    ///   types with `duration_iso` set.
612    /// - [`Error::Signing`] for malformed subject / duration / etc.
613    pub async fn record_action(&self, req: RecordActionRequest) -> Result<RecordedAction> {
614        let (reply_tx, reply_rx) = oneshot::channel();
615        self.tx
616            .send(WriteCommand::RecordAction(req, reply_tx))
617            .await
618            .map_err(|_| Error::Signing("writer task is shut down".into()))?;
619        reply_rx
620            .await
621            .map_err(|_| Error::Signing("writer dropped reply channel".into()))?
622    }
623
624    /// Revoke a previously-recorded action (§F20 / #51). Errors:
625    ///
626    /// - [`Error::ActionNotFound`] when no row matches `action_id`.
627    /// - [`Error::ActionAlreadyRevoked`] when the row's revoked_at
628    ///   is already non-NULL — the schema trigger forbids
629    ///   re-revocation; the handler catches it before the UPDATE.
630    pub async fn revoke_action(&self, req: RevokeActionRequest) -> Result<RevokedAction> {
631        let (reply_tx, reply_rx) = oneshot::channel();
632        self.tx
633            .send(WriteCommand::RevokeAction(req, reply_tx))
634            .await
635            .map_err(|_| Error::Signing("writer task is shut down".into()))?;
636        reply_rx
637            .await
638            .map_err(|_| Error::Signing("writer dropped reply channel".into()))?
639    }
640
641    /// Append a hash-chained audit row through the writer task (#39).
642    /// Used by in-process callers that don't already hold a transaction
643    /// — e.g., `retentionSweep`'s post-sweep audit row. Callers that
644    /// have an open transaction (writer-internal handlers,
645    /// `flag_reporter`) use [`crate::audit::append::append_in_tx`]
646    /// directly so the audit row commits atomically with the rest of
647    /// their work. Cross-process CLIs (publish/unpublish-service-
648    /// record) use [`crate::audit::append::append_via_pool`].
649    ///
650    /// Returns the inserted `audit_log.id`.
651    pub async fn append_audit(&self, row: crate::audit::append::AuditRowForAppend) -> Result<i64> {
652        let (reply_tx, reply_rx) = oneshot::channel();
653        self.tx
654            .send(WriteCommand::AppendAudit(row, reply_tx))
655            .await
656            .map_err(|_| Error::Signing("writer task is shut down".into()))?;
657        reply_rx
658            .await
659            .map_err(|_| Error::Signing("writer dropped reply channel".into()))?
660    }
661
662    /// Subscribe to committed events. The returned receiver lags past
663    /// the internal broadcast buffer; the consumer (subscribeLabels, #7)
664    /// turns `RecvError::Lagged` into a connection close.
665    pub fn subscribe(&self) -> broadcast::Receiver<LabelEvent> {
666        self.broadcast_tx.subscribe()
667    }
668
669    /// Count of live broadcast receivers. Exposed for test sync points
670    /// (the WS handler subscribes asynchronously after upgrade; tests
671    /// that want to emit an event "after the subscriber is ready" poll
672    /// this until it reaches the expected count). Not a production hot
673    /// path — `broadcast::Sender::receiver_count` walks an atomic chain.
674    pub fn receiver_count(&self) -> usize {
675        self.broadcast_tx.receiver_count()
676    }
677
678    /// Observe writer lifecycle. The returned receiver starts at `false`
679    /// and flips to `true` once exactly, when the writer has accepted a
680    /// shutdown command and is about to release the lease. Holders close
681    /// downstream connections gracefully on the `true` transition.
682    pub fn shutdown_signal(&self) -> watch::Receiver<bool> {
683        self.shutdown_rx.clone()
684    }
685
686    /// Explicit shutdown. Drains in-flight writes, releases the lease
687    /// row, stops the heartbeat, and returns. Idempotent-ish: calling on
688    /// an already-shut-down writer returns a channel-closed error, not a
689    /// panic.
690    pub async fn shutdown(&self) -> Result<()> {
691        let (reply_tx, reply_rx) = oneshot::channel();
692        self.tx
693            .send(WriteCommand::Shutdown(reply_tx))
694            .await
695            .map_err(|_| Error::Signing("writer task is already shut down".into()))?;
696        reply_rx
697            .await
698            .map_err(|_| Error::Signing("writer dropped reply channel".into()))?
699    }
700}
701
702/// Start the writer task. Acquires the instance lease, bootstraps the
703/// `signing_keys` row if empty, and spawns the event loop + heartbeat.
704///
705/// `service_did` stamps `labels.src` on every emitted event. `key` must
706/// be the private key whose public form is recorded (or will be recorded)
707/// in `signing_keys`.
708///
709/// `retention_days` is the §F4 retention cutoff in days. `None` disables
710/// the retention sweep entirely (`WriteCommand::Sweep` becomes a no-op
711/// returning `rows_deleted = 0`); `Some(N)` lets the sweep delete labels
712/// whose `created_at` is older than `now - N days`. Source of truth is
713/// [`crate::SubscribeConfig::retention_days`] — pass it through verbatim.
714///
715/// `retention` is the sweep execution policy (schedule + batching);
716/// distinct from `retention_days` per the [§F4 split](crate::RetentionConfig).
717///
718/// `reason_vocabulary` and `strike_policy` are the resolved v1.4
719/// moderation surface (#47 / #48). The recorder (#51 RecordAction
720/// handler) consults them at action time to validate reason codes
721/// and compute strike values. They're held by the writer task so
722/// the recorder doesn't have to thread them through every call.
723/// Operators restart `cairn serve` to change either — same posture
724/// as `[labeler]` config.
725pub async fn spawn(
726    pool: Pool<Sqlite>,
727    key: SigningKey,
728    service_did: String,
729    retention_days: Option<u32>,
730    retention: RetentionConfig,
731    reason_vocabulary: ReasonVocabulary,
732    strike_policy: StrikePolicy,
733) -> Result<WriterHandle> {
734    let instance_id = acquire_lease(&pool).await?;
735    let signing_key_id = ensure_signing_key_row(&pool, &key).await?;
736
737    let (tx, rx) = mpsc::channel(COMMAND_BUFFER);
738    let (broadcast_tx, _first_rx) = broadcast::channel(BROADCAST_BUFFER);
739    let (shutdown_tx, shutdown_rx) = watch::channel(false);
740
741    let writer = Writer {
742        pool,
743        key,
744        service_did,
745        signing_key_id,
746        instance_id,
747        rx,
748        broadcast_tx: broadcast_tx.clone(),
749        shutdown_tx,
750        retention_days,
751        retention,
752        reason_vocabulary,
753        strike_policy,
754    };
755
756    tokio::spawn(writer.run());
757
758    Ok(WriterHandle {
759        tx,
760        broadcast_tx,
761        shutdown_rx,
762    })
763}
764
765// ---------- Writer task ----------
766
767struct Writer {
768    pool: Pool<Sqlite>,
769    key: SigningKey,
770    service_did: String,
771    signing_key_id: i64,
772    instance_id: String,
773    rx: mpsc::Receiver<WriteCommand>,
774    broadcast_tx: broadcast::Sender<LabelEvent>,
775    shutdown_tx: watch::Sender<bool>,
776    /// Retention cutoff in days. `None` makes the sweep a no-op.
777    /// Source of truth is [`crate::SubscribeConfig::retention_days`].
778    retention_days: Option<u32>,
779    /// Sweep execution policy (schedule + batching).
780    retention: RetentionConfig,
781    /// Resolved [moderation_reasons] vocabulary (#47). Consulted by
782    /// the RecordAction handler to validate reason codes and look
783    /// up base_weight / severe.
784    reason_vocabulary: ReasonVocabulary,
785    /// Resolved [strike_policy] (#48). Consulted by the RecordAction
786    /// handler for the dampening curve, threshold, and decay
787    /// parameters.
788    strike_policy: StrikePolicy,
789}
790
791/// Internal accumulator for an in-flight scheduled sweep. Lives only
792/// while [`Writer::run`] is mid-sweep; absent between sweeps.
793struct SweepRunState {
794    started_at: Instant,
795    rows: i64,
796    batches: u64,
797}
798
799impl Writer {
800    /// Compute the next absolute [`Instant`] at which the scheduled
801    /// sweep should fire, or `None` when the sweep is disabled.
802    ///
803    /// "Disabled" = `sweep_enabled = false` OR `retention_days = None`.
804    /// In the second case the sweep would be a no-op anyway; skipping
805    /// the timer entirely keeps the run loop quiet.
806    ///
807    /// Targets the next occurrence of `sweep_run_at_utc_hour:00:00`
808    /// UTC. If we're already past that hour today, target tomorrow at
809    /// the same hour.
810    fn compute_next_sweep_fire(&self) -> Option<Instant> {
811        if !self.retention.sweep_enabled || self.retention_days.is_none() {
812            return None;
813        }
814        let now_utc = OffsetDateTime::now_utc();
815        let target_hour = self.retention.sweep_run_at_utc_hour;
816        let target_today_time = time::Time::from_hms(target_hour, 0, 0)
817            .expect("hour validated < 24 by Config::validate");
818        let target_today = now_utc.replace_time(target_today_time);
819        let target_dt = if target_today <= now_utc {
820            target_today + time::Duration::days(1)
821        } else {
822            target_today
823        };
824        let wait = target_dt - now_utc;
825        let wait_secs = wait.whole_seconds().max(0) as u64;
826        Some(Instant::now() + Duration::from_secs(wait_secs))
827    }
828
829    async fn run(mut self) {
830        let mut heartbeat_timer = interval(HEARTBEAT_INTERVAL);
831        heartbeat_timer.set_missed_tick_behavior(MissedTickBehavior::Delay);
832        // First tick fires immediately; skip it — we just acquired the lease
833        // with a fresh timestamp, so touching it again is redundant.
834        heartbeat_timer.tick().await;
835
836        // Scheduled-sweep wiring (§F4). The check timer wakes the run
837        // loop once a minute to evaluate "is it time to start a sweep?";
838        // when a sweep starts, `sweep_state` is populated and the
839        // immediate-batch arm runs one batch per loop iteration until
840        // `has_more = false`. Inter-batch yielding to incoming commands
841        // is automatic — the biased select prefers `rx.recv()` and
842        // `heartbeat_timer.tick()` over running another batch.
843        let mut sweep_check_timer = interval(SWEEP_CHECK_INTERVAL);
844        sweep_check_timer.set_missed_tick_behavior(MissedTickBehavior::Delay);
845        sweep_check_timer.tick().await;
846
847        let mut next_scheduled_fire: Option<Instant> = self.compute_next_sweep_fire();
848        let mut sweep_state: Option<SweepRunState> = None;
849
850        loop {
851            let sweep_in_progress = sweep_state.is_some();
852            tokio::select! {
853                biased;
854                // Prefer draining write commands over heartbeats so a
855                // steady-state of inbound work does not starve itself
856                // behind a background housekeeping task.
857                cmd = self.rx.recv() => {
858                    match cmd {
859                        Some(WriteCommand::Apply(req, reply)) => {
860                            let res = self.handle_apply(req).await;
861                            // Caller may have cancelled; dropping the reply is fine.
862                            let _ = reply.send(res);
863                        }
864                        Some(WriteCommand::Negate(req, reply)) => {
865                            let res = self.handle_negate(req).await;
866                            let _ = reply.send(res);
867                        }
868                        Some(WriteCommand::ResolveReport(req, reply)) => {
869                            let res = self.handle_resolve_report(req).await;
870                            let _ = reply.send(res);
871                        }
872                        Some(WriteCommand::Sweep(req, reply)) => {
873                            // Single-batch dispatch — the caller loops
874                            // until has_more=false. Per-batch return
875                            // lets the writer's biased select pick up
876                            // pending Apply / Negate / ResolveReport
877                            // commands between batches (§F4 + §F5).
878                            let res = self.handle_sweep(req).await;
879                            let _ = reply.send(res);
880                        }
881                        Some(WriteCommand::AppendAudit(row, reply)) => {
882                            let res = self.handle_append_audit(row).await;
883                            let _ = reply.send(res);
884                        }
885                        Some(WriteCommand::RecordAction(req, reply)) => {
886                            let res = self.handle_record_action(req).await;
887                            let _ = reply.send(res);
888                        }
889                        Some(WriteCommand::RevokeAction(req, reply)) => {
890                            let res = self.handle_revoke_action(req).await;
891                            let _ = reply.send(res);
892                        }
893                        Some(WriteCommand::Shutdown(reply)) => {
894                            // Flip the shutdown watch *before* releasing the
895                            // lease so subscriber tasks see the signal while
896                            // the DB is still accessible for their close-
897                            // frame sends.
898                            let _ = self.shutdown_tx.send(true);
899                            let res = self.release_lease().await;
900                            let _ = reply.send(res);
901                            return;
902                        }
903                        None => {
904                            // All handles dropped without explicit shutdown.
905                            // Best-effort lease release so a same-process
906                            // restart doesn't trip the 60s wait.
907                            let _ = self.shutdown_tx.send(true);
908                            if let Err(e) = self.release_lease().await {
909                                tracing::error!("lease release on handle drop: {e}");
910                            }
911                            return;
912                        }
913                    }
914                }
915                _ = heartbeat_timer.tick() => {
916                    if let Err(e) = self.heartbeat().await {
917                        // Transient DB errors are logged, not fatal. A
918                        // sustained failure is caught at the next-instance
919                        // startup check — not at this writer's expense.
920                        tracing::error!("lease heartbeat failed: {e}");
921                    }
922                }
923                _ = sweep_check_timer.tick() => {
924                    if sweep_state.is_none()
925                        && let Some(fire_at) = next_scheduled_fire
926                        && Instant::now() >= fire_at
927                    {
928                        sweep_state = Some(SweepRunState {
929                            started_at: Instant::now(),
930                            rows: 0,
931                            batches: 0,
932                        });
933                        next_scheduled_fire = Some(fire_at + Duration::from_secs(86_400));
934                        tracing::info!(
935                            retention_days = ?self.retention_days,
936                            sweep_batch_size = self.retention.sweep_batch_size,
937                            "scheduled retention sweep starting"
938                        );
939                    }
940                }
941                // Always-ready arm gated on sweep_in_progress. With biased
942                // order this ranks below rx.recv() + the two timers, so
943                // normal commands and heartbeats interleave naturally
944                // between batches (§F4 inter-batch yield, §F5 single-
945                // writer invariant).
946                _ = std::future::ready(()), if sweep_in_progress => {
947                    match self.handle_sweep(SweepRequest).await {
948                        Ok(batch) => {
949                            let state = sweep_state
950                                .as_mut()
951                                .expect("sweep_in_progress => sweep_state Some");
952                            state.rows += batch.rows_deleted;
953                            state.batches += 1;
954                            if !batch.has_more {
955                                let final_state = sweep_state.take().expect("just set");
956                                tracing::info!(
957                                    rows_deleted = final_state.rows,
958                                    batches = final_state.batches,
959                                    duration_ms = final_state.started_at.elapsed().as_millis() as u64,
960                                    retention_days_applied = ?batch.retention_days_applied,
961                                    "scheduled retention sweep complete"
962                                );
963                            }
964                        }
965                        Err(e) => {
966                            let final_state = sweep_state.take();
967                            tracing::error!(
968                                error = %e,
969                                batches_done = final_state.as_ref().map(|s| s.batches).unwrap_or(0),
970                                rows_so_far = final_state.as_ref().map(|s| s.rows).unwrap_or(0),
971                                "scheduled retention sweep batch failed; aborting run (will retry on next schedule)"
972                            );
973                        }
974                    }
975                }
976            }
977        }
978    }
979
980    /// Process one batch of the §F4 retention sweep. Single-batch
981    /// dispatch — see [`WriteCommand::Sweep`] for the loop ordering.
982    ///
983    /// Returns immediately with `rows_deleted = 0, has_more = false`
984    /// when `retention_days` is `None`. Otherwise issues one
985    /// `DELETE FROM labels WHERE created_at < cutoff LIMIT N` in its
986    /// own transaction; sets `has_more = true` iff the batch hit the
987    /// `sweep_batch_size` limit (suggesting more rows may match).
988    ///
989    /// Errors are propagated as `Err(_)` rather than swallowed: a
990    /// transient DB failure should surface to the operator on a
991    /// manual sweep, and to the schedule-loop logger on a scheduled
992    /// sweep. Idempotency (Q5) means the next sweep retries cleanly.
993    async fn handle_sweep(&self, _req: SweepRequest) -> Result<SweepBatchResult> {
994        let Some(days) = self.retention_days else {
995            return Ok(SweepBatchResult {
996                rows_deleted: 0,
997                has_more: false,
998                retention_days_applied: None,
999            });
1000        };
1001
1002        let cutoff_ms = epoch_ms_now() - (days as i64) * 86_400_000;
1003        let limit = self.retention.sweep_batch_size;
1004
1005        let mut tx = self.pool.begin().await?;
1006        // SQLite's DELETE doesn't support LIMIT without the
1007        // SQLITE_ENABLE_UPDATE_DELETE_LIMIT compile flag (off in
1008        // bundled builds). The rowid IN (SELECT ... LIMIT) trick is
1009        // the canonical portable workaround.
1010        let result = sqlx::query!(
1011            "DELETE FROM labels WHERE rowid IN (
1012               SELECT rowid FROM labels WHERE created_at < ?1 LIMIT ?2
1013             )",
1014            cutoff_ms,
1015            limit,
1016        )
1017        .execute(&mut *tx)
1018        .await?;
1019        tx.commit().await?;
1020
1021        let rows = result.rows_affected() as i64;
1022        Ok(SweepBatchResult {
1023            rows_deleted: rows,
1024            has_more: rows >= limit,
1025            retention_days_applied: Some(days),
1026        })
1027    }
1028
1029    /// Handler for [`WriteCommand::AppendAudit`]. Opens its own
1030    /// transaction (BEGIN DEFERRED — fine because the writer task is
1031    /// the only in-process audit appender during a `cairn serve`
1032    /// session, and cross-process appenders use BEGIN IMMEDIATE on
1033    /// their side), inserts the audit row with a freshly-computed
1034    /// hash, commits.
1035    async fn handle_append_audit(
1036        &self,
1037        row: crate::audit::append::AuditRowForAppend,
1038    ) -> Result<i64> {
1039        let mut tx = self.pool.begin().await?;
1040        let id = crate::audit::append::append_in_tx(&mut tx, &row).await?;
1041        tx.commit().await?;
1042        Ok(id)
1043    }
1044
1045    async fn handle_apply(&self, req: ApplyLabelRequest) -> Result<LabelEvent> {
1046        let mut tx = self.pool.begin().await?;
1047        let created_at = epoch_ms_now();
1048        let event = self.apply_label_inner(&mut tx, &req, created_at).await?;
1049        tx.commit().await?;
1050        // No-receivers is not a write failure (§plan point G).
1051        let _ = self.broadcast_tx.send(event.clone());
1052        Ok(event)
1053    }
1054
1055    /// Inner label-application pipeline: reserve seq → clamp cts →
1056    /// sign → INSERT label → INSERT audit. Does NOT commit the
1057    /// transaction and does NOT broadcast — caller handles both.
1058    ///
1059    /// Extracted from `handle_apply` so `handle_resolve_report` can
1060    /// reuse it inside the same transaction as the report update,
1061    /// preserving §F5's single-writer-owns-label-emission invariant
1062    /// (this helper only runs on the writer task; no other code path
1063    /// can access seq allocation or cts clamping).
1064    async fn apply_label_inner(
1065        &self,
1066        tx: &mut sqlx::Transaction<'_, Sqlite>,
1067        req: &ApplyLabelRequest,
1068        created_at_ms: i64,
1069    ) -> Result<LabelEvent> {
1070        let seq = reserve_seq(tx).await?;
1071        let prev_cts: Option<String> = sqlx::query_scalar!(
1072            // sqlx type override — MAX() strips column origin metadata so
1073            // sqlx can't infer the TEXT+nullable shape on its own. `?:`
1074            // forces the nullable wrapper (Option<String>).
1075            r#"SELECT MAX(cts) AS "max_cts?: String" FROM labels
1076               WHERE src = ?1 AND uri = ?2 AND val = ?3"#,
1077            self.service_did,
1078            req.uri,
1079            req.val,
1080        )
1081        .fetch_one(&mut **tx)
1082        .await?;
1083
1084        let cts = clamp_cts(created_at_ms, prev_cts.as_deref())?;
1085
1086        let mut label = Label {
1087            ver: 1,
1088            src: self.service_did.clone(),
1089            uri: req.uri.clone(),
1090            cid: req.cid.clone(),
1091            val: req.val.clone(),
1092            neg: false,
1093            cts: cts.clone(),
1094            exp: req.exp.clone(),
1095            sig: None,
1096        };
1097        label.sig = Some(sign_label(&self.key, &label)?);
1098        let sig_bytes = label.sig.expect("just set").to_vec();
1099
1100        let neg_int: i64 = 0;
1101        sqlx::query!(
1102            "INSERT INTO labels (seq, ver, src, uri, cid, val, neg, cts, exp, sig, signing_key_id, created_at)
1103             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1104            seq,
1105            label.ver,
1106            label.src,
1107            label.uri,
1108            label.cid,
1109            label.val,
1110            neg_int,
1111            label.cts,
1112            label.exp,
1113            sig_bytes,
1114            self.signing_key_id,
1115            created_at_ms,
1116        )
1117        .execute(&mut **tx)
1118        .await?;
1119
1120        let audit_reason = build_audit_reason(&req.val, false, req.moderator_reason.as_deref());
1121        crate::audit::append::append_in_tx(
1122            tx,
1123            &crate::audit::append::AuditRowForAppend {
1124                created_at: created_at_ms,
1125                action: "label_applied".into(),
1126                actor_did: req.actor_did.clone(),
1127                target: Some(req.uri.clone()),
1128                target_cid: req.cid.clone(),
1129                outcome: "success".into(),
1130                reason: Some(audit_reason),
1131            },
1132        )
1133        .await?;
1134
1135        Ok(LabelEvent { seq, label })
1136    }
1137
1138    async fn handle_negate(&self, req: NegateLabelRequest) -> Result<LabelEvent> {
1139        let mut tx = self.pool.begin().await?;
1140
1141        // Most-recent event for the tuple. Not-found OR latest-is-already-
1142        // a-negation both surface as LabelNotFound (§F6).
1143        let latest = sqlx::query!(
1144            "SELECT neg, cid FROM labels
1145             WHERE src = ?1 AND uri = ?2 AND val = ?3
1146             ORDER BY seq DESC LIMIT 1",
1147            self.service_did,
1148            req.uri,
1149            req.val,
1150        )
1151        .fetch_optional(&mut *tx)
1152        .await?;
1153
1154        let cid = match latest {
1155            Some(row) if row.neg == 0 => row.cid,
1156            _ => {
1157                return Err(Error::LabelNotFound {
1158                    src: self.service_did.clone(),
1159                    uri: req.uri,
1160                    val: req.val,
1161                });
1162            }
1163        };
1164
1165        let seq = reserve_seq(&mut tx).await?;
1166        // Prev cts lookup is the same query as apply; tuple uniqueness is
1167        // on (src, uri, val) regardless of neg.
1168        let prev_cts: Option<String> = sqlx::query_scalar!(
1169            // sqlx type override — MAX() strips column origin metadata so
1170            // sqlx can't infer the TEXT+nullable shape on its own. `?:`
1171            // forces the nullable wrapper (Option<String>).
1172            r#"SELECT MAX(cts) AS "max_cts?: String" FROM labels
1173               WHERE src = ?1 AND uri = ?2 AND val = ?3"#,
1174            self.service_did,
1175            req.uri,
1176            req.val,
1177        )
1178        .fetch_one(&mut *tx)
1179        .await?;
1180
1181        let wall_now_ms = epoch_ms_now();
1182        let cts = clamp_cts(wall_now_ms, prev_cts.as_deref())?;
1183
1184        let mut label = Label {
1185            ver: 1,
1186            src: self.service_did.clone(),
1187            uri: req.uri.clone(),
1188            cid: cid.clone(),
1189            val: req.val.clone(),
1190            neg: true,
1191            cts: cts.clone(),
1192            exp: None, // Negations never carry an expiry.
1193            sig: None,
1194        };
1195        label.sig = Some(sign_label(&self.key, &label)?);
1196        let sig_bytes = label.sig.expect("just set").to_vec();
1197
1198        let created_at = wall_now_ms;
1199        let neg_int: i64 = 1;
1200        sqlx::query!(
1201            "INSERT INTO labels (seq, ver, src, uri, cid, val, neg, cts, exp, sig, signing_key_id, created_at)
1202             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1203            seq,
1204            label.ver,
1205            label.src,
1206            label.uri,
1207            label.cid,
1208            label.val,
1209            neg_int,
1210            label.cts,
1211            label.exp,
1212            sig_bytes,
1213            self.signing_key_id,
1214            created_at,
1215        )
1216        .execute(&mut *tx)
1217        .await?;
1218
1219        let audit_reason = build_audit_reason(&req.val, true, req.moderator_reason.as_deref());
1220        crate::audit::append::append_in_tx(
1221            &mut tx,
1222            &crate::audit::append::AuditRowForAppend {
1223                created_at,
1224                action: "label_negated".into(),
1225                actor_did: req.actor_did.clone(),
1226                target: Some(req.uri.clone()),
1227                target_cid: cid.clone(),
1228                outcome: "success".into(),
1229                reason: Some(audit_reason),
1230            },
1231        )
1232        .await?;
1233
1234        tx.commit().await?;
1235
1236        let event = LabelEvent { seq, label };
1237        let _ = self.broadcast_tx.send(event.clone());
1238        Ok(event)
1239    }
1240
1241    /// Atomic resolveReport flow — §F12 requires label INSERT +
1242    /// audit(label_applied) + report UPDATE + audit(report_resolved)
1243    /// all commit together or not at all. Everything happens inside
1244    /// one SQLite transaction on the writer task, preserving §F5's
1245    /// single-writer invariant for the optional label emission.
1246    ///
1247    /// Returns [`Error::ReportNotFound`] if the report doesn't
1248    /// exist, [`Error::ReportAlreadyResolved`] if it is not in
1249    /// `status='pending'`. Label-value validation is a handler
1250    /// concern (the handler consults `AdminConfig.label_values`
1251    /// before sending the command here — saves a writer round-trip
1252    /// on invalid input and keeps the anti-leak message local).
1253    async fn handle_resolve_report(&self, req: ResolveReportRequest) -> Result<ResolvedReport> {
1254        use crate::report::ReportStatus;
1255
1256        let mut tx = self.pool.begin().await?;
1257
1258        // 1. Load the report; confirm pending status.
1259        let current = sqlx::query_as!(
1260            crate::report::Report,
1261            r#"SELECT
1262                 id                 AS "id!: i64",
1263                 created_at         AS "created_at!: String",
1264                 reported_by        AS "reported_by!: String",
1265                 reason_type        AS "reason_type!: String",
1266                 reason,
1267                 subject_type       AS "subject_type!: String",
1268                 subject_did        AS "subject_did!: String",
1269                 subject_uri,
1270                 subject_cid,
1271                 status             AS "status!: ReportStatus",
1272                 resolved_at,
1273                 resolved_by,
1274                 resolution_label,
1275                 resolution_reason
1276               FROM reports WHERE id = ?1"#,
1277            req.report_id,
1278        )
1279        .fetch_optional(&mut *tx)
1280        .await?;
1281
1282        let mut report = current.ok_or(Error::ReportNotFound { id: req.report_id })?;
1283        if report.status != ReportStatus::Pending {
1284            return Err(Error::ReportAlreadyResolved { id: req.report_id });
1285        }
1286
1287        let created_at = epoch_ms_now();
1288
1289        // 2. Optional label-apply BEFORE the report UPDATE so audit
1290        // row insertion order reflects logical sequence
1291        // (label_applied, then report_resolved — §G per #15 criteria).
1292        let label_event = if let Some(apply) = req.action.as_apply() {
1293            let apply_req = ApplyLabelRequest {
1294                actor_did: req.actor_did.clone(),
1295                uri: apply.uri.clone(),
1296                cid: apply.cid.clone(),
1297                val: apply.val.clone(),
1298                exp: apply.exp.clone(),
1299                // The label's own audit_reason JSON captures {val,
1300                // neg, moderator_reason}; the resolution-level reason
1301                // goes on the report_resolved audit row below.
1302                moderator_reason: None,
1303            };
1304            Some(
1305                self.apply_label_inner(&mut tx, &apply_req, created_at)
1306                    .await?,
1307            )
1308        } else {
1309            None
1310        };
1311
1312        // 3. UPDATE reports.
1313        let resolved_at_rfc = rfc3339_from_epoch_ms(created_at)?;
1314        let resolution_label = req.action.as_apply().map(|a| a.val.clone());
1315        let resolved_status = ReportStatus::Resolved;
1316        sqlx::query!(
1317            "UPDATE reports SET
1318                status = ?1,
1319                resolved_at = ?2,
1320                resolved_by = ?3,
1321                resolution_label = ?4,
1322                resolution_reason = ?5
1323             WHERE id = ?6",
1324            resolved_status,
1325            resolved_at_rfc,
1326            req.actor_did,
1327            resolution_label,
1328            req.resolution_reason,
1329            req.report_id,
1330        )
1331        .execute(&mut *tx)
1332        .await?;
1333
1334        // 4. Audit: report_resolved.
1335        let audit_reason = build_resolve_audit_reason(
1336            req.action.as_apply().map(|a| a.val.as_str()),
1337            req.resolution_reason.as_deref(),
1338        );
1339        crate::audit::append::append_in_tx(
1340            &mut tx,
1341            &crate::audit::append::AuditRowForAppend {
1342                created_at,
1343                action: "report_resolved".into(),
1344                actor_did: req.actor_did.clone(),
1345                target: Some(req.report_id.to_string()),
1346                target_cid: None,
1347                outcome: "success".into(),
1348                reason: Some(audit_reason),
1349            },
1350        )
1351        .await?;
1352
1353        tx.commit().await?;
1354
1355        // 5. Broadcast (post-commit, per §F5 broadcast-after-commit rule).
1356        if let Some(event) = &label_event {
1357            let _ = self.broadcast_tx.send(event.clone());
1358        }
1359
1360        // 6. Mutate the loaded report struct to reflect the committed
1361        // state and return it. Avoids a re-fetch round-trip since we
1362        // know exactly what changed.
1363        report.status = ReportStatus::Resolved;
1364        report.resolved_at = Some(resolved_at_rfc);
1365        report.resolved_by = Some(req.actor_did);
1366        report.resolution_label = resolution_label;
1367        report.resolution_reason = req.resolution_reason;
1368
1369        Ok(ResolvedReport {
1370            report,
1371            label_event,
1372        })
1373    }
1374
1375    /// Atomic recordAction flow (§F20 / #51). Validates inputs,
1376    /// resolves the primary reason, computes strike values via the
1377    /// v1.4 calculators (#48/#49/#50/#51), inserts the audit_log
1378    /// row first (so subject_actions can carry audit_log_id at
1379    /// INSERT — the schema's no-update-except-revoke trigger
1380    /// forbids backfilling it later), inserts the subject_actions
1381    /// row, then UPSERTs subject_strike_state. All in one
1382    /// transaction.
1383    async fn handle_record_action(&self, req: RecordActionRequest) -> Result<RecordedAction> {
1384        // ---------- pre-flight validation ----------
1385
1386        if req.reason_codes.is_empty() {
1387            return Err(Error::Signing(
1388                "recordAction: reason_codes must be non-empty".into(),
1389            ));
1390        }
1391        // Note/Warning don't take reasons in the §F20 design intent,
1392        // but the issue body says "warning requires --reason" for
1393        // the audit trail. Accept reasons for all types; only
1394        // strike-bearing types actually use them for strike
1395        // calculation.
1396        match req.action_type {
1397            ActionType::TempSuspension => {
1398                if req.duration_iso.as_deref().unwrap_or("").is_empty() {
1399                    return Err(Error::DurationRequiredForTempSuspension);
1400                }
1401            }
1402            _ => {
1403                if req.duration_iso.is_some() {
1404                    return Err(Error::DurationOnlyForTempSuspension);
1405                }
1406            }
1407        }
1408
1409        let (subject_did, subject_uri) = route_subject(&req.subject)?;
1410
1411        // Resolve primary reason against the writer's vocabulary.
1412        // Vocabulary lookups are cheap; doing it before opening the
1413        // transaction keeps a bad-reason failure from acquiring the
1414        // SQLite write lock unnecessarily.
1415        let primary = resolve_primary_reason(&req.reason_codes, &self.reason_vocabulary)?;
1416
1417        // Parse duration (cheap; pre-tx for the same reason).
1418        let duration_secs = match (req.action_type, req.duration_iso.as_deref()) {
1419            (ActionType::TempSuspension, Some(iso)) => Some(parse_iso8601_duration(iso)?),
1420            _ => None,
1421        };
1422
1423        // ---------- transaction ----------
1424
1425        let mut tx = self.pool.begin().await?;
1426        let created_at = epoch_ms_now();
1427        let effective_at = created_at;
1428        let expires_at: Option<i64> = duration_secs.map(|s| effective_at + (s as i64) * 1000);
1429
1430        // Load history for strike + position calculation. id-ascending
1431        // (oldest-first) per the calculators' contract.
1432        let history = load_subject_actions_for_calc(&mut tx, &subject_did).await?;
1433
1434        // Compute current count via the decay calculator.
1435        let now_systemtime = epoch_ms_to_systemtime(created_at);
1436        let pre_state = calculate_strike_state(&history, &self.strike_policy, now_systemtime);
1437        let strikes_at_time_of_action = pre_state.current_count;
1438
1439        // Compute position and strike value.
1440        let position = compute_position_in_window(&history, &self.strike_policy, now_systemtime);
1441        let calc: StrikeApplication = strike_calculate(
1442            strikes_at_time_of_action,
1443            &primary,
1444            &self.strike_policy,
1445            position,
1446        );
1447
1448        // Note/Warning don't carry strikes regardless of reason
1449        // (§F20 design + #48 semantics). Override with zero values
1450        // and was_dampened=false; defense-in-depth for an operator
1451        // who attaches a strike-bearing reason to a Note row.
1452        let (strike_base, strike_applied, was_dampened) = match req.action_type {
1453            ActionType::Note | ActionType::Warning => (0u32, 0u32, false),
1454            _ => (calc.base_weight, calc.applied, calc.was_dampened),
1455        };
1456
1457        // Build audit_log reason JSON before the INSERT so the
1458        // audit row can carry the resolved values for forensic
1459        // reconstruction.
1460        // action_id is unknown until subject_actions INSERT, so we
1461        // patch it in after that step.
1462        let reason_codes_json = serde_json::to_string(&req.reason_codes)
1463            .map_err(|e| Error::Signing(format!("serialize reason_codes: {e}")))?;
1464        let report_ids_json = if req.report_ids.is_empty() {
1465            None
1466        } else {
1467            Some(
1468                serde_json::to_string(&req.report_ids)
1469                    .map_err(|e| Error::Signing(format!("serialize report_ids: {e}")))?,
1470            )
1471        };
1472
1473        // Append audit_log row first. target = subject_did (the
1474        // account being moderated); target_cid = None. We don't
1475        // know action_id yet — pass 0 as a placeholder; the actual
1476        // id is patched into the reason JSON after the
1477        // subject_actions INSERT, but only the reason JSON gets
1478        // the patch. The audit row's hash chain locks at this
1479        // INSERT, so the action_id is captured at write time.
1480        //
1481        // Order rationale: audit_log first so subject_actions
1482        // can carry audit_log_id at INSERT (the trigger forbids
1483        // UPDATE of audit_log_id). The audit row's reason JSON
1484        // captures the action_id IT will reference, computed
1485        // post-INSERT via the next-id query.
1486        let next_action_id = predict_next_subject_action_id(&mut tx).await?;
1487        let audit_reason = build_record_action_audit_reason(
1488            next_action_id,
1489            req.action_type,
1490            &primary.identifier,
1491            &req.reason_codes,
1492            strike_base,
1493            strike_applied,
1494            was_dampened,
1495        );
1496        let audit_target = subject_uri.clone().unwrap_or_else(|| subject_did.clone());
1497        let audit_log_id = crate::audit::append::append_in_tx(
1498            &mut tx,
1499            &crate::audit::append::AuditRowForAppend {
1500                created_at,
1501                action: "subject_action_recorded".into(),
1502                actor_did: req.actor_did.clone(),
1503                target: Some(audit_target),
1504                target_cid: None,
1505                outcome: "success".into(),
1506                reason: Some(audit_reason),
1507            },
1508        )
1509        .await?;
1510
1511        // INSERT subject_actions. action_type goes through
1512        // ActionType::as_db_str so the SQL CHECK constraint binds
1513        // the same set as the Rust enum.
1514        let action_type_str = req.action_type.as_db_str();
1515        let was_dampened_int: i64 = if was_dampened { 1 } else { 0 };
1516        let strikes_at_time_i64 = strikes_at_time_of_action as i64;
1517        let strike_base_i64 = strike_base as i64;
1518        let strike_applied_i64 = strike_applied as i64;
1519        let inserted_id = sqlx::query_scalar!(
1520            "INSERT INTO subject_actions (
1521                subject_did, subject_uri, actor_did, action_type, reason_codes,
1522                duration, effective_at, expires_at, notes, report_ids,
1523                strike_value_base, strike_value_applied, was_dampened,
1524                strikes_at_time_of_action, audit_log_id, created_at
1525             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
1526             RETURNING id",
1527            subject_did,
1528            subject_uri,
1529            req.actor_did,
1530            action_type_str,
1531            reason_codes_json,
1532            req.duration_iso,
1533            effective_at,
1534            expires_at,
1535            req.notes,
1536            report_ids_json,
1537            strike_base_i64,
1538            strike_applied_i64,
1539            was_dampened_int,
1540            strikes_at_time_i64,
1541            audit_log_id,
1542            created_at,
1543        )
1544        .fetch_one(&mut *tx)
1545        .await?;
1546
1547        if inserted_id != next_action_id {
1548            // Audit row already committed; we'd have to invalidate
1549            // it. With BEGIN DEFERRED + the writer task being the
1550            // only in-process appender, the predict-vs-insert
1551            // race is closed — but if it ever fires, surface as
1552            // an internal error rather than a silent mismatch.
1553            return Err(Error::Signing(format!(
1554                "subject_actions inserted at id {inserted_id} but predicted {next_action_id}; audit chain captured the predicted id (corrupted)"
1555            )));
1556        }
1557
1558        // Recompute strike state for the cache. Reload history with
1559        // the new row so the cache reflects post-insert reality.
1560        let post_history = load_subject_actions_for_calc(&mut tx, &subject_did).await?;
1561        let post_state = calculate_strike_state(&post_history, &self.strike_policy, now_systemtime);
1562
1563        let post_count_i64 = post_state.current_count as i64;
1564        sqlx::query!(
1565            "INSERT INTO subject_strike_state (subject_did, current_strike_count, last_action_at, last_recompute_at)
1566             VALUES (?1, ?2, ?3, ?3)
1567             ON CONFLICT(subject_did) DO UPDATE SET
1568                 current_strike_count = excluded.current_strike_count,
1569                 last_action_at = excluded.last_action_at,
1570                 last_recompute_at = excluded.last_recompute_at",
1571            subject_did,
1572            post_count_i64,
1573            created_at,
1574        )
1575        .execute(&mut *tx)
1576        .await?;
1577
1578        tx.commit().await?;
1579
1580        Ok(RecordedAction {
1581            action_id: inserted_id,
1582            strike_value_base: strike_base,
1583            strike_value_applied: strike_applied,
1584            was_dampened,
1585            strikes_at_time_of_action,
1586        })
1587    }
1588
1589    /// Atomic revokeAction flow (§F20 / #51). Looks up the row,
1590    /// verifies state, sets the revoked_* columns (the schema's
1591    /// no-update-except-revoke trigger permits this NULL→non-NULL
1592    /// transition), recomputes strike_state for the subject, and
1593    /// appends an audit_log row. All in one transaction.
1594    async fn handle_revoke_action(&self, req: RevokeActionRequest) -> Result<RevokedAction> {
1595        let mut tx = self.pool.begin().await?;
1596
1597        // Look up the row + check current state.
1598        let row = sqlx::query!(
1599            "SELECT subject_did, revoked_at FROM subject_actions WHERE id = ?1",
1600            req.action_id,
1601        )
1602        .fetch_optional(&mut *tx)
1603        .await?;
1604        let row = row.ok_or(Error::ActionNotFound(req.action_id))?;
1605        if row.revoked_at.is_some() {
1606            return Err(Error::ActionAlreadyRevoked(req.action_id));
1607        }
1608
1609        let revoked_at_ms = epoch_ms_now();
1610
1611        // UPDATE the revocation columns. The schema trigger permits
1612        // NULL→non-NULL on these three columns; any other column
1613        // change in this UPDATE would abort.
1614        sqlx::query!(
1615            "UPDATE subject_actions
1616             SET revoked_at = ?1, revoked_by_did = ?2, revoked_reason = ?3
1617             WHERE id = ?4",
1618            revoked_at_ms,
1619            req.revoked_by_did,
1620            req.revoked_reason,
1621            req.action_id,
1622        )
1623        .execute(&mut *tx)
1624        .await?;
1625
1626        // Recompute strike state for the subject. The decay
1627        // calculator excludes revoked rows from current_count.
1628        let post_history = load_subject_actions_for_calc(&mut tx, &row.subject_did).await?;
1629        let now_systemtime = epoch_ms_to_systemtime(revoked_at_ms);
1630        let post_state = calculate_strike_state(&post_history, &self.strike_policy, now_systemtime);
1631
1632        let post_count_i64 = post_state.current_count as i64;
1633        sqlx::query!(
1634            "INSERT INTO subject_strike_state (subject_did, current_strike_count, last_action_at, last_recompute_at)
1635             VALUES (?1, ?2, ?3, ?3)
1636             ON CONFLICT(subject_did) DO UPDATE SET
1637                 current_strike_count = excluded.current_strike_count,
1638                 last_recompute_at = excluded.last_recompute_at",
1639            row.subject_did,
1640            post_count_i64,
1641            revoked_at_ms,
1642        )
1643        .execute(&mut *tx)
1644        .await?;
1645
1646        // Audit row. target = subject_actions.id.to_string() so the
1647        // audit chain links back to the row being revoked (not the
1648        // subject, which is recoverable via the row id).
1649        let audit_reason =
1650            build_revoke_action_audit_reason(req.action_id, req.revoked_reason.as_deref());
1651        crate::audit::append::append_in_tx(
1652            &mut tx,
1653            &crate::audit::append::AuditRowForAppend {
1654                created_at: revoked_at_ms,
1655                action: "subject_action_revoked".into(),
1656                actor_did: req.revoked_by_did.clone(),
1657                target: Some(req.action_id.to_string()),
1658                target_cid: None,
1659                outcome: "success".into(),
1660                reason: Some(audit_reason),
1661            },
1662        )
1663        .await?;
1664
1665        tx.commit().await?;
1666
1667        Ok(RevokedAction {
1668            action_id: req.action_id,
1669            revoked_at: rfc3339_from_epoch_ms(revoked_at_ms)?,
1670        })
1671    }
1672
1673    async fn heartbeat(&self) -> Result<()> {
1674        let now_ms = epoch_ms_now();
1675        sqlx::query!(
1676            "UPDATE server_instance_lease SET last_heartbeat = ?1
1677             WHERE id = 1 AND instance_id = ?2",
1678            now_ms,
1679            self.instance_id,
1680        )
1681        .execute(&self.pool)
1682        .await?;
1683        Ok(())
1684    }
1685
1686    async fn release_lease(&self) -> Result<()> {
1687        sqlx::query!(
1688            "DELETE FROM server_instance_lease WHERE id = 1 AND instance_id = ?1",
1689            self.instance_id,
1690        )
1691        .execute(&self.pool)
1692        .await?;
1693        Ok(())
1694    }
1695}
1696
1697// ---------- Lease + key bootstrap ----------
1698
1699/// Acquire the §F5 single-instance lease.
1700///
1701/// Returns the new instance id on success. Returns
1702/// [`Error::LeaseHeld`] when an existing row's `last_heartbeat` is
1703/// still within [`LEASE_STALE_MS`] — i.e., another writer is live.
1704///
1705/// Exposed as `pub(crate)` so one-shot CLI tools that mutate
1706/// `audit_log` (`cairn audit-rebuild`, #40) can assert the same
1707/// "no live writer" invariant the long-running serve process holds.
1708/// The CLI tool releases via [`release_lease_by_id`] when done.
1709pub(crate) async fn acquire_lease(pool: &Pool<Sqlite>) -> Result<String> {
1710    let now_ms = epoch_ms_now();
1711
1712    let existing =
1713        sqlx::query!("SELECT instance_id, last_heartbeat FROM server_instance_lease WHERE id = 1")
1714            .fetch_optional(pool)
1715            .await?;
1716
1717    if let Some(row) = existing {
1718        let age_ms = (now_ms - row.last_heartbeat).max(0);
1719        if age_ms < LEASE_STALE_MS {
1720            return Err(Error::LeaseHeld {
1721                instance_id: row.instance_id,
1722                age_secs: (age_ms / 1000) as u64,
1723            });
1724        }
1725    }
1726
1727    let new_id = Uuid::new_v4().to_string();
1728    // INSERT OR REPLACE on id=1: takes over a stale lease row if present,
1729    // or creates the row on first startup. Legal because the staleness
1730    // check above confirms no live peer.
1731    sqlx::query!(
1732        "INSERT INTO server_instance_lease (id, instance_id, acquired_at, last_heartbeat)
1733         VALUES (1, ?1, ?2, ?2)
1734         ON CONFLICT(id) DO UPDATE SET
1735             instance_id = excluded.instance_id,
1736             acquired_at = excluded.acquired_at,
1737             last_heartbeat = excluded.last_heartbeat",
1738        new_id,
1739        now_ms,
1740    )
1741    .execute(pool)
1742    .await?;
1743
1744    Ok(new_id)
1745}
1746
1747/// Release a lease acquired via [`acquire_lease`] by its instance id.
1748/// Free-function counterpart to [`Writer::release_lease`] for callers
1749/// that don't own a `Writer` (one-shot CLI tools that just hold the
1750/// lease for the duration of a data migration).
1751pub(crate) async fn release_lease_by_id(pool: &Pool<Sqlite>, instance_id: &str) -> Result<()> {
1752    sqlx::query!(
1753        "DELETE FROM server_instance_lease WHERE id = 1 AND instance_id = ?1",
1754        instance_id,
1755    )
1756    .execute(pool)
1757    .await?;
1758    Ok(())
1759}
1760
1761async fn ensure_signing_key_row(pool: &Pool<Sqlite>, key: &SigningKey) -> Result<i64> {
1762    let kp = K256Keypair::from_private_key(key.expose_secret())?;
1763    let my_multibase = format_multikey("ES256K", &kp.public_key_compressed());
1764
1765    let existing =
1766        sqlx::query!("SELECT id, public_key_multibase FROM signing_keys ORDER BY id LIMIT 1")
1767            .fetch_optional(pool)
1768            .await?;
1769
1770    if let Some(row) = existing {
1771        if row.public_key_multibase != my_multibase {
1772            return Err(Error::Signing(format!(
1773                "signing_keys.public_key_multibase ({}) does not match the loaded signing key's derived public key ({}) — key rotation is v1.1 scope",
1774                row.public_key_multibase, my_multibase
1775            )));
1776        }
1777        return Ok(row.id);
1778    }
1779
1780    let now_ms = epoch_ms_now();
1781    let valid_from = rfc3339_from_epoch_ms(now_ms)?;
1782    let id = sqlx::query_scalar!(
1783        "INSERT INTO signing_keys (public_key_multibase, valid_from, valid_to, created_at)
1784         VALUES (?1, ?2, NULL, ?3)
1785         RETURNING id",
1786        my_multibase,
1787        valid_from,
1788        now_ms,
1789    )
1790    .fetch_one(pool)
1791    .await?;
1792
1793    Ok(id)
1794}
1795
1796// ---------- Pure helpers (unit-testable without a DB) ----------
1797
1798async fn reserve_seq(tx: &mut sqlx::SqliteConnection) -> Result<i64> {
1799    // label_sequence has a single auto-increment column; `DEFAULT VALUES`
1800    // triggers a fresh allocation. Under AUTOINCREMENT the assigned seq
1801    // is never reused even across rolled-back transactions — this is what
1802    // gives us "strictly monotonic, no gaps from writer's perspective."
1803    let seq = sqlx::query_scalar!("INSERT INTO label_sequence DEFAULT VALUES RETURNING seq")
1804        .fetch_one(tx)
1805        .await?;
1806    Ok(seq)
1807}
1808
1809/// §6.1 monotonicity clamp. `prev_cts_str`, when present, is expected to
1810/// be in the writer's canonical `CTS_FORMAT`.
1811fn clamp_cts(wall_now_ms: i64, prev_cts_str: Option<&str>) -> Result<String> {
1812    let effective_ms = match prev_cts_str {
1813        Some(s) => {
1814            let prev_ms = parse_rfc3339_ms(s)?;
1815            wall_now_ms.max(prev_ms + 1)
1816        }
1817        None => wall_now_ms,
1818    };
1819    rfc3339_from_epoch_ms(effective_ms)
1820}
1821
1822/// Epoch-ms → RFC-3339 Z with millisecond precision. `pub(crate)` so
1823/// peer modules (admin/audit_view, future retention sweep) share the
1824/// single formatter used on the writer's timestamp boundary.
1825pub(crate) fn rfc3339_from_epoch_ms(ms: i64) -> Result<String> {
1826    let nanos: i128 = (ms as i128) * 1_000_000;
1827    let dt = OffsetDateTime::from_unix_timestamp_nanos(nanos)
1828        .map_err(|e| Error::Signing(format!("epoch ms {ms} out of range: {e}")))?;
1829    let formatted = dt
1830        .format(&CTS_FORMAT)
1831        .map_err(|e| Error::Signing(format!("format cts: {e}")))?;
1832    Ok(format!("{formatted}Z"))
1833}
1834
1835/// RFC-3339 Z with millisecond precision → epoch-ms. `pub(crate)` so
1836/// admin handlers can validate `since` / `until` query params against
1837/// the same parser the writer uses on its input boundary.
1838pub(crate) fn parse_rfc3339_ms(s: &str) -> Result<i64> {
1839    let stripped = s
1840        .strip_suffix('Z')
1841        .ok_or_else(|| Error::Signing(format!("cts {s:?} missing trailing Z")))?;
1842    let pdt = PrimitiveDateTime::parse(stripped, &CTS_FORMAT)
1843        .map_err(|e| Error::Signing(format!("parse cts {s:?}: {e}")))?;
1844    let nanos = pdt.assume_utc().unix_timestamp_nanos();
1845    Ok((nanos / 1_000_000) as i64)
1846}
1847
1848/// Current wall-clock time as Unix epoch milliseconds. `pub(crate)` so
1849/// peer modules (server, future retention sweep) share one implementation
1850/// rather than each inlining `SystemTime::now()` with its own error
1851/// handling.
1852pub(crate) fn epoch_ms_now() -> i64 {
1853    SystemTime::now()
1854        .duration_since(UNIX_EPOCH)
1855        .expect("system clock before unix epoch")
1856        .as_millis() as i64
1857}
1858
1859fn build_audit_reason(val: &str, neg: bool, moderator_reason: Option<&str>) -> String {
1860    let body = serde_json::json!({
1861        "val": val,
1862        "neg": neg,
1863        "moderator_reason": moderator_reason,
1864    });
1865    body.to_string()
1866}
1867
1868/// `report_resolved` audit reason JSON — see [`AUDIT_REASON_RESOLVE_REPORT`]
1869/// for the schema.
1870fn build_resolve_audit_reason(
1871    applied_label_val: Option<&str>,
1872    resolution_reason: Option<&str>,
1873) -> String {
1874    serde_json::json!({
1875        "applied_label_val": applied_label_val,
1876        "resolution_reason": resolution_reason,
1877    })
1878    .to_string()
1879}
1880
1881/// `retention_sweep` audit reason JSON — see
1882/// [`AUDIT_REASON_RETENTION_SWEEP`] for the schema. Shared with the
1883/// retentionSweep admin handler (audited only on the operator-
1884/// initiated path per Q6/D2).
1885pub(crate) fn build_retention_sweep_audit_reason(result: &SweepResult) -> String {
1886    serde_json::json!({
1887        "rows_deleted": result.rows_deleted,
1888        "batches": result.batches,
1889        "duration_ms": result.duration_ms,
1890        "retention_days_applied": result.retention_days_applied,
1891    })
1892    .to_string()
1893}
1894
1895/// Audit-log `reason` JSON schema for `subject_action_recorded`
1896/// (§F20 / #51 graduated-action moderation). Captures the action_id,
1897/// action_type, primary reason resolved at record time, the full
1898/// list of declared reasons, the strike value applied, and whether
1899/// dampening fired — enough for forensic reconstruction without
1900/// requiring a join to subject_actions.
1901///
1902/// ```json
1903/// {
1904///   "action_id": <i64>,
1905///   "action_type": "<warning|note|temp_suspension|indef_suspension|takedown>",
1906///   "primary_reason": "<identifier>",
1907///   "reason_codes": ["<id1>", "<id2>"],
1908///   "strike_value_base": <u32>,
1909///   "strike_value_applied": <u32>,
1910///   "was_dampened": <bool>
1911/// }
1912/// ```
1913#[doc(alias = "audit_log.reason.subject_action_recorded")]
1914pub const AUDIT_REASON_RECORD_ACTION: &str = "subject_action_recorded: { action_id, action_type, primary_reason, reason_codes, strike_value_base, strike_value_applied, was_dampened }";
1915
1916/// Audit-log `reason` JSON schema for `subject_action_revoked`
1917/// (§F20 / #51). Captures the revoked subject_actions row id and
1918/// the moderator-supplied rationale.
1919///
1920/// ```json
1921/// {
1922///   "action_id": <i64>,
1923///   "revoked_reason": "<free text>" | null
1924/// }
1925/// ```
1926#[doc(alias = "audit_log.reason.subject_action_revoked")]
1927pub const AUDIT_REASON_REVOKE_ACTION: &str =
1928    "subject_action_revoked: { action_id, revoked_reason }";
1929
1930/// `reporter_flagged` / `reporter_unflagged` audit reason JSON —
1931/// see [`AUDIT_REASON_FLAG_REPORTER`]. Shared with the flagReporter
1932/// handler (not the writer — flagReporter is a direct handler txn
1933/// per the #15 criteria).
1934pub(crate) fn build_flag_reporter_audit_reason(
1935    did: &str,
1936    suppressed: bool,
1937    moderator_reason: Option<&str>,
1938) -> String {
1939    serde_json::json!({
1940        "did": did,
1941        "suppressed": suppressed,
1942        "moderator_reason": moderator_reason,
1943    })
1944    .to_string()
1945}
1946
1947/// `subject_action_recorded` audit reason JSON — see
1948/// [`AUDIT_REASON_RECORD_ACTION`] for the schema.
1949fn build_record_action_audit_reason(
1950    action_id: i64,
1951    action_type: ActionType,
1952    primary_reason: &str,
1953    reason_codes: &[String],
1954    strike_value_base: u32,
1955    strike_value_applied: u32,
1956    was_dampened: bool,
1957) -> String {
1958    serde_json::json!({
1959        "action_id": action_id,
1960        "action_type": action_type.as_db_str(),
1961        "primary_reason": primary_reason,
1962        "reason_codes": reason_codes,
1963        "strike_value_base": strike_value_base,
1964        "strike_value_applied": strike_value_applied,
1965        "was_dampened": was_dampened,
1966    })
1967    .to_string()
1968}
1969
1970/// `subject_action_revoked` audit reason JSON — see
1971/// [`AUDIT_REASON_REVOKE_ACTION`] for the schema.
1972fn build_revoke_action_audit_reason(action_id: i64, revoked_reason: Option<&str>) -> String {
1973    serde_json::json!({
1974        "action_id": action_id,
1975        "revoked_reason": revoked_reason,
1976    })
1977    .to_string()
1978}
1979
1980/// Convert epoch-ms (i64) to [`SystemTime`]. The v1.4 calculators
1981/// take SystemTime; the schema stores epoch-ms; this is the
1982/// boundary helper. Negative values clamp to UNIX_EPOCH (defense:
1983/// schema columns are non-negative in practice).
1984fn epoch_ms_to_systemtime(ms: i64) -> SystemTime {
1985    if ms >= 0 {
1986        UNIX_EPOCH + Duration::from_millis(ms as u64)
1987    } else {
1988        UNIX_EPOCH
1989    }
1990}
1991
1992/// Route a subject string into (subject_did, subject_uri) per the
1993/// recorder's contract. DIDs go to subject_did with no URI; AT-URIs
1994/// extract the repo DID as subject_did and keep the full URI as
1995/// subject_uri. Anything else is rejected.
1996fn route_subject(subject: &str) -> Result<(String, Option<String>)> {
1997    if let Some(rest) = subject.strip_prefix("at://") {
1998        let repo = rest.split('/').next().unwrap_or("");
1999        if repo.is_empty() || !repo.starts_with("did:") {
2000            return Err(Error::Signing(format!(
2001                "subject at://-URI must start at://did:.../...; got {subject:?}"
2002            )));
2003        }
2004        Ok((repo.to_string(), Some(subject.to_string())))
2005    } else if subject.starts_with("did:") && subject.len() > "did:".len() {
2006        Ok((subject.to_string(), None))
2007    } else {
2008        Err(Error::Signing(format!(
2009            "subject must be a DID (`did:...`) or AT-URI (`at://did:...`); got {subject:?}"
2010        )))
2011    }
2012}
2013
2014/// Parse a narrow ISO-8601 duration subset into a [`Duration`].
2015/// Supports `P{n}D` (days), `P{n}W` (weeks), `PT{n}H` (hours),
2016/// `PT{n}M` (minutes), `PT{n}S` (seconds), and combinations like
2017/// `P1DT12H`. Years and months are NOT supported — moderation
2018/// suspensions are bounded enough that day/hour granularity covers
2019/// real cases without the calendar-arithmetic complexity of Y/M.
2020///
2021/// Returns the duration in seconds.
2022fn parse_iso8601_duration(s: &str) -> Result<u64> {
2023    let body = s.strip_prefix('P').ok_or_else(|| {
2024        Error::Signing(format!(
2025            "duration {s:?} must start with 'P' (ISO-8601 duration form, e.g. P7D)"
2026        ))
2027    })?;
2028    if body.is_empty() {
2029        return Err(Error::Signing(format!(
2030            "duration {s:?} has no components after 'P'"
2031        )));
2032    }
2033
2034    // Split into date part (before T, if any) and time part.
2035    let (date_part, time_part) = match body.split_once('T') {
2036        Some((d, t)) => (d, Some(t)),
2037        None => (body, None),
2038    };
2039
2040    let mut total_secs: u64 = 0;
2041    let mut buf = String::new();
2042    for c in date_part.chars() {
2043        if c.is_ascii_digit() {
2044            buf.push(c);
2045            continue;
2046        }
2047        let n: u64 = buf
2048            .parse()
2049            .map_err(|_| Error::Signing(format!("duration {s:?}: malformed numeric run")))?;
2050        buf.clear();
2051        let mult = match c {
2052            'D' => 86_400u64,
2053            'W' => 7 * 86_400u64,
2054            'Y' | 'M' => {
2055                return Err(Error::Signing(format!(
2056                    "duration {s:?}: years (Y) and months (M, without T prefix) not supported in v1.4"
2057                )));
2058            }
2059            other => {
2060                return Err(Error::Signing(format!(
2061                    "duration {s:?}: unknown date-part unit {other:?}"
2062                )));
2063            }
2064        };
2065        total_secs = total_secs.saturating_add(n.saturating_mul(mult));
2066    }
2067    if !buf.is_empty() {
2068        return Err(Error::Signing(format!(
2069            "duration {s:?}: trailing digits without unit in date part"
2070        )));
2071    }
2072
2073    if let Some(time_body) = time_part {
2074        if time_body.is_empty() {
2075            return Err(Error::Signing(format!(
2076                "duration {s:?}: 'T' separator with no time components"
2077            )));
2078        }
2079        for c in time_body.chars() {
2080            if c.is_ascii_digit() {
2081                buf.push(c);
2082                continue;
2083            }
2084            let n: u64 = buf.parse().map_err(|_| {
2085                Error::Signing(format!(
2086                    "duration {s:?}: malformed numeric run in time part"
2087                ))
2088            })?;
2089            buf.clear();
2090            let mult = match c {
2091                'H' => 3600u64,
2092                'M' => 60u64,
2093                'S' => 1u64,
2094                other => {
2095                    return Err(Error::Signing(format!(
2096                        "duration {s:?}: unknown time-part unit {other:?}"
2097                    )));
2098                }
2099            };
2100            total_secs = total_secs.saturating_add(n.saturating_mul(mult));
2101        }
2102        if !buf.is_empty() {
2103            return Err(Error::Signing(format!(
2104                "duration {s:?}: trailing digits without unit in time part"
2105            )));
2106        }
2107    }
2108
2109    if total_secs == 0 {
2110        return Err(Error::Signing(format!(
2111            "duration {s:?}: parsed to zero — must be at least one second"
2112        )));
2113    }
2114    Ok(total_secs)
2115}
2116
2117/// Predict the next AUTOINCREMENT id for `subject_actions`. SQLite's
2118/// AUTOINCREMENT columns are backed by `sqlite_sequence`; the next
2119/// value is `max(seq, max(rowid)) + 1`. With BEGIN DEFERRED + the
2120/// writer task being the only in-process appender, the predict
2121/// step is racy only against external processes — and the ROLLBACK
2122/// path catches mismatch via the inserted_id check in
2123/// handle_record_action.
2124async fn predict_next_subject_action_id(tx: &mut sqlx::Transaction<'_, Sqlite>) -> Result<i64> {
2125    // sqlite_sequence.seq has no NOT NULL constraint; the type
2126    // override forces a non-nullable i64 because the column is
2127    // populated for every AUTOINCREMENT table that has had at
2128    // least one row inserted (NULL only happens if the row
2129    // doesn't exist, which fetch_optional handles).
2130    let row = sqlx::query!(
2131        r#"SELECT seq AS "seq!: i64" FROM sqlite_sequence WHERE name = 'subject_actions'"#
2132    )
2133    .fetch_optional(&mut **tx)
2134    .await?;
2135    Ok(row.map(|r| r.seq + 1).unwrap_or(1))
2136}
2137
2138/// Load subject_actions rows for a subject, oldest-first, projecting
2139/// to [`ActionRecord`] for the v1.4 calculators (decay #50,
2140/// window #51). Filters to the columns the calculators consume —
2141/// the rest stay in the DB.
2142async fn load_subject_actions_for_calc(
2143    tx: &mut sqlx::Transaction<'_, Sqlite>,
2144    subject_did: &str,
2145) -> Result<Vec<ActionRecord>> {
2146    let rows = sqlx::query!(
2147        "SELECT action_type, strike_value_applied, was_dampened,
2148                effective_at, expires_at, revoked_at
2149         FROM subject_actions
2150         WHERE subject_did = ?1
2151         ORDER BY id ASC",
2152        subject_did,
2153    )
2154    .fetch_all(&mut **tx)
2155    .await?;
2156
2157    let mut out = Vec::with_capacity(rows.len());
2158    for r in rows {
2159        let action_type = ActionType::from_db_str(&r.action_type).ok_or_else(|| {
2160            Error::Signing(format!(
2161                "subject_actions row has invalid action_type {:?}",
2162                r.action_type
2163            ))
2164        })?;
2165        let strike_value_applied = u32::try_from(r.strike_value_applied).map_err(|_| {
2166            Error::Signing(format!(
2167                "subject_actions row strike_value_applied {} out of u32 range",
2168                r.strike_value_applied
2169            ))
2170        })?;
2171        out.push(ActionRecord {
2172            strike_value_applied,
2173            effective_at: epoch_ms_to_systemtime(r.effective_at),
2174            revoked_at: r.revoked_at.map(epoch_ms_to_systemtime),
2175            action_type,
2176            expires_at: r.expires_at.map(epoch_ms_to_systemtime),
2177            was_dampened: r.was_dampened != 0,
2178        });
2179    }
2180    Ok(out)
2181}
2182
2183#[cfg(test)]
2184mod tests {
2185    use super::*;
2186
2187    // 1_715_000_000_000 ms since epoch = 2024-05-06T12:53:20.000Z UTC.
2188    // The four tests below pin every branch of the clamp: no prior,
2189    // wall-ahead, wall-behind (use prev+1ms), wall-equal (advance anyway).
2190
2191    #[test]
2192    fn clamp_cts_uses_wall_clock_when_no_prior() {
2193        let out = clamp_cts(1_715_000_000_000, None).expect("clamp");
2194        assert_eq!(out, "2024-05-06T12:53:20.000Z");
2195    }
2196
2197    #[test]
2198    fn clamp_cts_advances_one_ms_past_prior_when_wall_clock_lags() {
2199        // wall_clock one second behind prev: result is prev + 1ms, not wall.
2200        let prev = "2024-05-06T12:53:20.500Z";
2201        let out = clamp_cts(1_715_000_000_000 - 1000, Some(prev)).expect("clamp");
2202        assert_eq!(out, "2024-05-06T12:53:20.501Z");
2203    }
2204
2205    #[test]
2206    fn clamp_cts_uses_wall_clock_when_ahead_of_prior() {
2207        let prev = "2024-05-06T12:53:20.500Z";
2208        let out = clamp_cts(1_715_000_000_000 + 2000, Some(prev)).expect("clamp");
2209        assert_eq!(out, "2024-05-06T12:53:22.000Z");
2210    }
2211
2212    #[test]
2213    fn clamp_cts_handles_equal_wall_and_prior_by_advancing() {
2214        // wall_clock == prev exactly: must advance by 1ms (strictly greater).
2215        let prev = "2024-05-06T12:53:20.500Z";
2216        let out = clamp_cts(1_715_000_000_500, Some(prev)).expect("clamp");
2217        assert_eq!(out, "2024-05-06T12:53:20.501Z");
2218    }
2219
2220    #[test]
2221    fn build_audit_reason_shape_matches_documented_schema() {
2222        let json = build_audit_reason("spam", false, Some("user reported"));
2223        let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
2224        assert_eq!(v["val"], "spam");
2225        assert_eq!(v["neg"], false);
2226        assert_eq!(v["moderator_reason"], "user reported");
2227    }
2228
2229    #[test]
2230    fn build_audit_reason_null_when_moderator_reason_absent() {
2231        let json = build_audit_reason("spam", true, None);
2232        let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
2233        assert!(v["moderator_reason"].is_null());
2234    }
2235
2236    #[test]
2237    fn rfc3339_roundtrip() {
2238        let s = "2026-04-22T12:00:00.938Z";
2239        let ms = parse_rfc3339_ms(s).expect("parse");
2240        let back = rfc3339_from_epoch_ms(ms).expect("format");
2241        assert_eq!(back, s);
2242    }
2243
2244    // ---------- route_subject (#51) ----------
2245
2246    #[test]
2247    fn route_subject_did_only_returns_did_no_uri() {
2248        let (did, uri) = route_subject("did:plc:abc123").unwrap();
2249        assert_eq!(did, "did:plc:abc123");
2250        assert!(uri.is_none());
2251    }
2252
2253    #[test]
2254    fn route_subject_at_uri_extracts_repo_did_and_keeps_full_uri() {
2255        let (did, uri) = route_subject("at://did:plc:abc/app.bsky.feed.post/3xx").unwrap();
2256        assert_eq!(did, "did:plc:abc");
2257        assert_eq!(
2258            uri.as_deref(),
2259            Some("at://did:plc:abc/app.bsky.feed.post/3xx")
2260        );
2261    }
2262
2263    #[test]
2264    fn route_subject_rejects_at_uri_with_non_did_repo() {
2265        let err = route_subject("at://handle.example/app.bsky.feed.post/3xx").unwrap_err();
2266        assert!(matches!(err, Error::Signing(_)));
2267    }
2268
2269    #[test]
2270    fn route_subject_rejects_arbitrary_string() {
2271        assert!(route_subject("not a subject").is_err());
2272        assert!(route_subject("did:").is_err());
2273        assert!(route_subject("").is_err());
2274    }
2275
2276    // ---------- parse_iso8601_duration (#51) ----------
2277
2278    #[test]
2279    fn duration_p7d_is_seven_days() {
2280        assert_eq!(parse_iso8601_duration("P7D").unwrap(), 7 * 86_400);
2281    }
2282
2283    #[test]
2284    fn duration_p1w_is_seven_days() {
2285        assert_eq!(parse_iso8601_duration("P1W").unwrap(), 7 * 86_400);
2286    }
2287
2288    #[test]
2289    fn duration_pt12h_is_twelve_hours() {
2290        assert_eq!(parse_iso8601_duration("PT12H").unwrap(), 12 * 3600);
2291    }
2292
2293    #[test]
2294    fn duration_pt30m_is_thirty_minutes() {
2295        assert_eq!(parse_iso8601_duration("PT30M").unwrap(), 30 * 60);
2296    }
2297
2298    #[test]
2299    fn duration_pt45s_is_forty_five_seconds() {
2300        assert_eq!(parse_iso8601_duration("PT45S").unwrap(), 45);
2301    }
2302
2303    #[test]
2304    fn duration_p1d_t12h_combines() {
2305        assert_eq!(
2306            parse_iso8601_duration("P1DT12H").unwrap(),
2307            86_400 + 12 * 3600
2308        );
2309    }
2310
2311    #[test]
2312    fn duration_no_p_prefix_rejected() {
2313        assert!(parse_iso8601_duration("7D").is_err());
2314        assert!(parse_iso8601_duration("").is_err());
2315    }
2316
2317    #[test]
2318    fn duration_p_alone_rejected() {
2319        assert!(parse_iso8601_duration("P").is_err());
2320    }
2321
2322    #[test]
2323    fn duration_year_rejected_in_v14() {
2324        let err = parse_iso8601_duration("P1Y").unwrap_err();
2325        let msg = format!("{err}");
2326        assert!(msg.contains("not supported"));
2327    }
2328
2329    #[test]
2330    fn duration_unknown_unit_rejected() {
2331        assert!(parse_iso8601_duration("P5X").is_err());
2332        assert!(parse_iso8601_duration("PT5X").is_err());
2333    }
2334
2335    #[test]
2336    fn duration_zero_rejected() {
2337        assert!(parse_iso8601_duration("P0D").is_err());
2338    }
2339
2340    #[test]
2341    fn duration_trailing_digits_without_unit_rejected() {
2342        assert!(parse_iso8601_duration("P5").is_err());
2343        assert!(parse_iso8601_duration("PT12").is_err());
2344    }
2345
2346    #[test]
2347    fn duration_t_separator_with_no_time_rejected() {
2348        assert!(parse_iso8601_duration("P1DT").is_err());
2349    }
2350
2351    // ---------- audit reason builders (#51) ----------
2352
2353    #[test]
2354    fn record_action_audit_reason_shape() {
2355        let json = build_record_action_audit_reason(
2356            42,
2357            ActionType::TempSuspension,
2358            "spam",
2359            &["spam".to_string(), "harassment".to_string()],
2360            4,
2361            2,
2362            true,
2363        );
2364        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2365        assert_eq!(v["action_id"], 42);
2366        assert_eq!(v["action_type"], "temp_suspension");
2367        assert_eq!(v["primary_reason"], "spam");
2368        assert_eq!(v["reason_codes"], serde_json::json!(["spam", "harassment"]));
2369        assert_eq!(v["strike_value_base"], 4);
2370        assert_eq!(v["strike_value_applied"], 2);
2371        assert_eq!(v["was_dampened"], true);
2372    }
2373
2374    #[test]
2375    fn revoke_action_audit_reason_with_reason() {
2376        let json = build_revoke_action_audit_reason(7, Some("appeal granted"));
2377        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2378        assert_eq!(v["action_id"], 7);
2379        assert_eq!(v["revoked_reason"], "appeal granted");
2380    }
2381
2382    #[test]
2383    fn revoke_action_audit_reason_without_reason_is_null() {
2384        let json = build_revoke_action_audit_reason(7, None);
2385        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
2386        assert!(v["revoked_reason"].is_null());
2387    }
2388}