//! Community signal aggregation with per-contributor attribution (M9).
//!
//! Mirrors [`CohortSignalLedger`](crate::cohort::CohortSignalLedger) — decay,
//! velocity, windowed counts keyed by `(community, entity, signal_type)` — but
//! retains **per-contributor** breakdown so a single user's contributions can be
//! removed (M9p3 retroactive purge) and the community aggregate rematerialized
//! *deterministically*.
//!
//! The aggregate for `(community, entity, signal_type)` is the sum over all
//! contributors of their decayed score / windowed count. Summation is performed
//! in a fixed `(user, epoch)` order so the result is bit-stable regardless of
//! insertion order — this is what makes a purge + rematerialization reproducible
//! (M9p3 "rebuilt aggregates identical across repeated replay").

use std::collections::HashMap;

use dashmap::DashMap;

use super::{membership::UserId, scope::CommunityId};
use crate::{
    schema::{DecayModel, EntityId, Schema, SchemaError, TidalError, Timestamp, Window},
    signals::{
        SignalTypeId,
        checkpoint::format::{deserialize_entry, serialize_entry},
        hot::HotSignalState,
        ledger::types::EntitySignalEntry,
        warm::BucketedCounter,
    },
    storage::{StorageEngine, Tag, WriteBatch, encode_key, entity_tag_prefix},
};

/// The writer that produced a contribution.
///
/// An agent id, or `""` for a direct (non-agent) user write. This is the M10p3
/// provenance anchor that makes a contribution removable by *writer* (agent),
/// not just by user.
pub type WriterTag = String;

/// A contributor key: which user contributed, via which writer agent, under
/// which membership epoch.
///
/// The epoch lets a purge target a specific membership lifecycle; the writer
/// lets a remove-by-scope target a specific agent's contributions (M10p3).
pub type ContributorKey = (UserId, WriterTag, u32);

/// One contributor's attributed share of a community aggregate (M10p3
/// explainability): who wrote it (`user` + `writer_agent`), under which `epoch`,
/// and its decayed `score` / windowed `count`.
#[derive(Debug, Clone, PartialEq)]
pub struct ContributionProvenance {
    /// The user the contribution is attributed to.
    pub user: UserId,
    /// The writer agent (`""` = direct user write).
    pub writer_agent: String,
    /// The membership epoch the contribution was made under.
    pub epoch: u32,
    /// The contribution's decayed score at the explain time.
    pub score: f64,
    /// The contribution's all-time windowed count.
    pub count: u64,
}

/// Per-`(community, entity, signal_type)` cell holding each contributor's
/// running decay + windowed state separately.
#[derive(Default)]
struct CommunityCell {
    contributors: HashMap<ContributorKey, EntitySignalEntry>,
}

/// Community signal ledger with per-contributor attribution.
pub struct CommunityLedger {
    cells: DashMap<(CommunityId, EntityId, SignalTypeId), CommunityCell>,
    signal_lambdas: HashMap<SignalTypeId, Vec<f64>>,
    signal_name_to_id: HashMap<String, SignalTypeId>,
    /// Latest purge watermark (wall-clock ns) per community, surfaced to queries.
    purge_watermarks: DashMap<CommunityId, u64>,
}

impl std::fmt::Debug for CommunityLedger {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CommunityLedger")
            .field("cells", &self.cells.len())
            .field("signal_types", &self.signal_name_to_id.len())
            .finish_non_exhaustive()
    }
}

impl CommunityLedger {
    /// Construct an empty ledger with no signal types (no-schema mode).
    #[must_use]
    pub fn empty() -> Self {
        Self {
            cells: DashMap::with_shard_amount(16),
            signal_lambdas: HashMap::new(),
            signal_name_to_id: HashMap::new(),
            purge_watermarks: DashMap::new(),
        }
    }

    /// Construct from a validated schema, building the same lambda map as the
    /// global and cohort ledgers (signal types enumerated alphabetically).
    #[must_use]
    pub fn new(schema: &Schema) -> Self {
        let mut signal_list: Vec<&crate::schema::SignalTypeDef> = schema.signals().collect();
        signal_list.sort_by_key(|s| s.name());

        let mut signal_name_to_id = HashMap::with_capacity(signal_list.len());
        let mut signal_lambdas = HashMap::with_capacity(signal_list.len());
        for (idx, sig) in signal_list.iter().enumerate() {
            let type_id = SignalTypeId::new(idx as u16);
            signal_name_to_id.insert(sig.name().to_owned(), type_id);
            let lambdas = match sig.decay() {
                DecayModel::Exponential { lambda, .. } => vec![*lambda],
                DecayModel::Linear { .. } | DecayModel::Permanent => vec![],
            };
            signal_lambdas.insert(type_id, lambdas);
        }

        Self {
            cells: DashMap::with_shard_amount(16),
            signal_lambdas,
            signal_name_to_id,
            purge_watermarks: DashMap::new(),
        }
    }

    /// Record a purge watermark for a community (monotonic: keeps the max).
    pub fn set_purge_watermark(&self, community: CommunityId, watermark_ns: u64) {
        self.purge_watermarks
            .entry(community)
            .and_modify(|w| *w = (*w).max(watermark_ns))
            .or_insert(watermark_ns);
    }

    /// The latest purge watermark for a community, if any purge has occurred.
    #[must_use]
    pub fn purge_watermark(&self, community: CommunityId) -> Option<u64> {
        self.purge_watermarks.get(&community).map(|w| *w)
    }

    /// Resolve a signal type name to its id.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Schema` if the name is unknown.
    pub fn resolve_signal_type(&self, name: &str) -> crate::Result<SignalTypeId> {
        self.signal_name_to_id
            .get(name)
            .copied()
            .ok_or_else(|| TidalError::Schema(SchemaError::UnknownSignalType(name.to_owned())))
    }

    /// Record a community contribution from `(user, epoch)`.
    ///
    /// Best-effort like the cohort ledger: it does not fail the primary write.
    // Flat signal coordinates (community/entity/type/user/epoch/weight/ts) mirror
    // the ledger key + value rather than wrapping them in a struct.
    #[allow(clippy::too_many_arguments)]
    pub fn record(
        &self,
        community: CommunityId,
        entity_id: EntityId,
        type_id: SignalTypeId,
        user: UserId,
        writer_agent: &str,
        epoch: u32,
        weight: f64,
        ts_ns: u64,
    ) {
        let lambdas = self
            .signal_lambdas
            .get(&type_id)
            .map_or_else(<&[f64]>::default, Vec::as_slice);
        let mut cell = self
            .cells
            .entry((community, entity_id, type_id))
            .or_default();
        let entry = cell
            .contributors
            .entry((user, writer_agent.to_owned(), epoch))
            .or_insert_with(|| EntitySignalEntry {
                hot: HotSignalState::new(entity_id.as_u64(), type_id.as_u16()),
                warm: BucketedCounter::with_start_time(ts_ns),
            });
        entry.hot.on_signal(weight, ts_ns, lambdas);
        entry.warm.increment(ts_ns);
        drop(cell);
    }

    /// The aggregate decayed score for `(community, entity, signal_type)` at
    /// `now_ns`: the sum over contributors, folded in a fixed `(user, epoch)`
    /// order for bit-stable, replay-reproducible results.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Schema` if the signal type name is unknown.
    pub fn decay_score_at(
        &self,
        community: CommunityId,
        entity_id: EntityId,
        signal_type_name: &str,
        decay_rate_idx: usize,
        now_ns: u64,
    ) -> crate::Result<Option<f64>> {
        let type_id = self.resolve_signal_type(signal_type_name)?;
        let lambda = self
            .signal_lambdas
            .get(&type_id)
            .and_then(|v| v.get(decay_rate_idx))
            .copied()
            .unwrap_or(0.0);
        let Some(cell) = self.cells.get(&(community, entity_id, type_id)) else {
            return Ok(None);
        };
        if cell.contributors.is_empty() {
            return Ok(None);
        }
        // Deterministic fold order: sort the (key, entry) pairs by key. We sort
        // entries we already hold rather than sorting keys and re-`get`ting each:
        // the keys came from this same map under this read guard, so the second
        // lookup is provably redundant (and its `if let Some` implies a
        // fallibility that cannot occur).
        let mut entries: Vec<(&ContributorKey, &EntitySignalEntry)> =
            cell.contributors.iter().collect();
        // Sort by the contributor key. The key borrows from the element, so this
        // uses `sort_unstable_by` (a borrowing `sort_by_key` closure cannot satisfy
        // the `FnMut(&T) -> K` lifetime); clippy's `unnecessary_sort_by` does not
        // fire here for that reason. `x.0` / `y.0` are `&ContributorKey`.
        entries.sort_unstable_by(|x, y| x.0.cmp(y.0));
        let mut sum = 0.0;
        for (_k, entry) in entries {
            sum += entry.hot.current_score(decay_rate_idx, now_ns, lambda);
        }
        Ok(Some(sum))
    }

    /// The aggregate windowed event count for `(community, entity, signal_type)`
    /// at `now_ns`.
    ///
    /// Takes the query clock explicitly so a multi-candidate query reads one
    /// consistent `now_ns` across this and its sibling reads
    /// ([`decay_score_at`](Self::decay_score_at), [`explain`](Self::explain),
    /// [`aggregate_digest`](Self::aggregate_digest)) — every contributor's warm
    /// tier is aged out against the *same* instant rather than each read
    /// re-sampling the wall clock (W20/W35, `CODING_GUIDELINES` §8).
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Schema` if the signal type name is unknown.
    pub fn windowed_count_at(
        &self,
        community: CommunityId,
        entity_id: EntityId,
        signal_type_name: &str,
        window: Window,
        now_ns: u64,
    ) -> crate::Result<u64> {
        let type_id = self.resolve_signal_type(signal_type_name)?;
        let Some(cell) = self.cells.get(&(community, entity_id, type_id)) else {
            return Ok(0);
        };
        // Age every contributor's warm tier out at the supplied read clock, so a
        // quiet community-entity's windowed count decays instead of freezing at
        // its peak (CRITICAL #6, CODING_GUIDELINES §8).
        Ok(cell
            .contributors
            .values()
            .map(|e| e.warm.windowed_count(window, now_ns))
            .sum())
    }

    /// The aggregate windowed event count for `(community, entity, signal_type)`
    /// at the current wall clock.
    ///
    /// Thin wrapper over [`windowed_count_at`](Self::windowed_count_at): it reads
    /// `Timestamp::now()` once and delegates. Callers that already hold a query
    /// clock should call `windowed_count_at` directly so they share one consistent
    /// `now_ns` across sibling reads.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Schema` if the signal type name is unknown.
    pub fn windowed_count(
        &self,
        community: CommunityId,
        entity_id: EntityId,
        signal_type_name: &str,
        window: Window,
    ) -> crate::Result<u64> {
        self.windowed_count_at(
            community,
            entity_id,
            signal_type_name,
            window,
            Timestamp::now().as_nanos(),
        )
    }

    /// Number of distinct contributors to a community across all entities/types.
    ///
    /// A contributor `(user, writer, epoch)` may appear in many cells (one per
    /// `(entity, signal_type)` they touched), so the count must dedup across
    /// cells. Rather than clone every `ContributorKey` (its `writer` is a heap
    /// `String`) into a set, we fold each key into a 128-bit BLAKE3 digest and
    /// dedup the digests — distinct counting with no per-key allocation. BLAKE3's
    /// 128-bit truncation makes a collision between two real contributor keys
    /// astronomically unlikely (the same content-addressed-dedup assumption the
    /// rest of tidalDB relies on).
    ///
    /// # Cost
    ///
    /// `O(total cells across all communities)` — there is no per-community cell
    /// index, so this scans every cell and filters by `community`. That matches
    /// the purge paths ([`purge_matching`](Self::purge_matching)) and is fine for
    /// the inspection/admin contexts this serves; it is *not* a per-candidate
    /// query-hot path. Building a `community -> cells` secondary index would add
    /// a write-path maintenance cost to every `record`/`purge` to speed up a cold
    /// read, which is the wrong trade until a profile shows this dominates.
    #[must_use]
    pub fn contributor_count(&self, community: CommunityId) -> usize {
        let mut digests = std::collections::HashSet::new();
        for cell in &self.cells {
            if cell.key().0 == community {
                for (user, writer, epoch) in cell.value().contributors.keys() {
                    let mut hasher = blake3::Hasher::new();
                    hasher.update(&user.as_u64().to_le_bytes());
                    hasher.update(&epoch.to_le_bytes());
                    hasher.update(writer.as_bytes());
                    let h = hasher.finalize();
                    // BLAKE3 always finalizes to 32 bytes, so the 16-byte
                    // truncation is infallible; `expect` documents the invariant
                    // rather than guarding a reachable failure.
                    let digest = u128::from_le_bytes(
                        h.as_bytes()[..16].try_into().expect("16 bytes from BLAKE3"),
                    );
                    digests.insert(digest);
                }
            }
        }
        digests.len()
    }

    /// Remove all contributions by `user` to `community` within `epoch_range`
    /// (inclusive). Returns the number of contributor entries removed.
    ///
    /// This is the M9p3 retroactive purge. Because the aggregate is a derived
    /// sum over the remaining contributors, removing the user's entries *is* the
    /// rematerialization — no separate rebuild pass is needed, and the result is
    /// deterministic and idempotent (a second purge removes nothing and leaves
    /// the aggregate unchanged).
    #[must_use]
    pub fn purge_contributor(
        &self,
        community: CommunityId,
        user: UserId,
        epoch_range: std::ops::RangeInclusive<u32>,
    ) -> usize {
        self.purge_matching(community, |(u, _writer, ep)| {
            *u == user && epoch_range.contains(ep)
        })
    }

    /// Remove all contributions written by `writer_agent` to `community`
    /// (M10p3 remove-by-scope = agent). Returns the number of entries removed.
    ///
    /// Like [`purge_contributor`](Self::purge_contributor), removal *is* the
    /// rematerialization: the derived aggregate recomputes deterministically over
    /// the remaining contributors.
    ///
    /// An empty `writer_agent` removes nothing and returns 0: the empty string is
    /// the sentinel for direct (non-agent) user writes, so matching it would purge
    /// every direct user contribution. Guarding here makes the footgun inert even
    /// if a caller bypasses the API-boundary validation (CRITICAL fix).
    #[must_use]
    pub fn purge_writer(&self, community: CommunityId, writer_agent: &str) -> usize {
        if writer_agent.is_empty() {
            return 0;
        }
        self.purge_matching(community, |(_u, writer, _ep)| writer == writer_agent)
    }

    /// Shared purge core: remove every contributor in `community` matching
    /// `pred`, dropping cells that become empty so they read as `None`.
    fn purge_matching(
        &self,
        community: CommunityId,
        pred: impl Fn(&ContributorKey) -> bool,
    ) -> usize {
        let mut removed = 0;
        let mut empty_cells = Vec::new();
        for mut cell in self.cells.iter_mut() {
            if cell.key().0 != community {
                continue;
            }
            let before = cell.value().contributors.len();
            cell.value_mut().contributors.retain(|k, _| !pred(k));
            removed += before - cell.value().contributors.len();
            if cell.value().contributors.is_empty() {
                empty_cells.push(*cell.key());
            }
        }
        for key in empty_cells {
            self.cells.remove(&key);
        }
        removed
    }

    /// Provenance attribution for `(community, entity, signal_type)`: each
    /// surviving contributor's `(user, writer_agent, epoch, decayed_score)` at
    /// `now_ns`, sorted deterministically. This is the M10p3 explainability
    /// surface — it attributes a community aggregate to the exact contributions
    /// that compose it.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Schema` if the signal type name is unknown.
    pub fn explain(
        &self,
        community: CommunityId,
        entity_id: EntityId,
        signal_type_name: &str,
        now_ns: u64,
    ) -> crate::Result<Vec<ContributionProvenance>> {
        let type_id = self.resolve_signal_type(signal_type_name)?;
        let lambda = self
            .signal_lambdas
            .get(&type_id)
            .and_then(|v| v.first())
            .copied()
            .unwrap_or(0.0);
        let Some(cell) = self.cells.get(&(community, entity_id, type_id)) else {
            return Ok(Vec::new());
        };
        // Sort the (key, entry) pairs we already hold; no re-`get` of keys that
        // came from this same map under this read guard.
        let mut entries: Vec<(&ContributorKey, &EntitySignalEntry)> =
            cell.contributors.iter().collect();
        // Sort by the contributor key. The key borrows from the element, so this
        // uses `sort_unstable_by` (a borrowing `sort_by_key` closure cannot satisfy
        // the `FnMut(&T) -> K` lifetime); clippy's `unnecessary_sort_by` does not
        // fire here for that reason. `x.0` / `y.0` are `&ContributorKey`.
        entries.sort_unstable_by(|x, y| x.0.cmp(y.0));
        let mut out = Vec::with_capacity(entries.len());
        for (k, e) in entries {
            out.push(ContributionProvenance {
                user: k.0,
                writer_agent: k.1.clone(),
                epoch: k.2,
                score: e.hot.current_score(0, now_ns, lambda),
                count: e.warm.windowed_count(Window::AllTime, now_ns),
            });
        }
        Ok(out)
    }

    /// Durably persist a SINGLE contributor cell as one `Tag::Community` row —
    /// the incremental, O(1)-per-write analogue of [`checkpoint`](Self::checkpoint)
    /// for the per-accept community-contribution path.
    ///
    /// `checkpoint` rewrites the *entire* `Tag::Community` keyspace (scan-delete
    /// every row, then re-put every in-memory cell). Calling it on every accepted
    /// contribution makes ingestion O(N²) — the M-th write does O(M) work — and
    /// blows the signal-write latency budget for any active community. This writes
    /// only the one row the accept just touched, so the per-accept storage cost is
    /// constant. The full scan-delete-rewrite snapshot stays in `checkpoint` for
    /// the lifecycle/periodic/backup path, where it garbage-collects purged rows.
    ///
    /// # Durability
    ///
    /// This ledger is the SOLE durable copy of per-contributor identity — the WAL
    /// community event carries none, so WAL replay cannot rebuild it. A bare
    /// `write_batch` is committed-not-durable (the bytes may still sit in a
    /// memtable / page cache, per [`StorageEngine::write_batch`]), so this issues
    /// the `fsync` barrier via [`StorageEngine::flush`] before returning `Ok(())`.
    /// That makes the accepted cell genuinely durable when the caller returns —
    /// honoring the `signal_for_community` "ZERO loss window" guarantee against a
    /// power-loss crash, not merely against a clean shutdown. The flush is on the
    /// gated, community-scoped contribution path (not the hot global signal path),
    /// so the per-accept `fsync` is an acceptable cost for the only state in the
    /// zone that cannot be recovered from the WAL.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Storage` on a write or flush failure.
    #[allow(clippy::too_many_arguments)]
    pub fn persist_cell(
        &self,
        storage: &dyn StorageEngine,
        community: CommunityId,
        entity_id: EntityId,
        type_id: SignalTypeId,
        user: UserId,
        writer_agent: &str,
        epoch: u32,
    ) -> crate::Result<()> {
        // Serialize the affected entry inside a scoped read guard, then drop the
        // guard BEFORE the storage write so no DashMap shard lock is held across
        // I/O. If the cell/contributor is absent (it should exist immediately
        // after `record`), there is nothing to persist.
        let value = {
            let Some(cell) = self.cells.get(&(community, entity_id, type_id)) else {
                return Ok(());
            };
            let Some(entry) = cell
                .contributors
                .get(&(user, writer_agent.to_owned(), epoch))
            else {
                return Ok(());
            };
            serialize_entry(entity_id, type_id, entry)
        };
        let suffix =
            encode_contributor_suffix(community, user, epoch, entity_id, type_id, writer_agent);
        let key = encode_key(EntityId::new(0), Tag::Community, &suffix);
        let mut batch = WriteBatch::new();
        batch.put(key, value);
        storage.write_batch(batch).map_err(TidalError::from)?;
        // Commit is not durability: fsync the keyspace so the contributor row
        // survives a power crash. This ledger has no WAL backstop, so without the
        // barrier the accepted cell could be lost while the WAL still reports the
        // aggregate event — exactly the gap the "ZERO loss window" claim forbids.
        storage.flush().map_err(TidalError::from)?;
        Ok(())
    }

    /// Checkpoint all per-contributor entries to durable storage under
    /// `Tag::Community`, one row per
    /// `(community, user, epoch, entity, signal_type, writer)`.
    ///
    /// The community aggregate's WAL events do not carry contributor identity, so
    /// (unlike the global ledger) it cannot be rebuilt from the WAL — this
    /// checkpoint is its durability mechanism. Written after a purge so the
    /// persisted state already reflects the purge.
    ///
    /// The storage key must distinguish every `(entity, signal_type)` a single
    /// contributor touches; otherwise multiple cells for the same
    /// `(community, user, epoch, writer)` would collide on one key and silently
    /// overwrite each other (last-write-wins), losing all but one cell on every
    /// restart. The suffix codec (see [`encode_contributor_suffix`]) therefore
    /// includes `entity` and `signal_type`.
    ///
    /// # Errors
    ///
    /// Returns `TidalError::Storage` on a write failure.
    pub fn checkpoint(&self, storage: &dyn StorageEngine) -> crate::Result<()> {
        // This ledger is the SOLE durable copy of per-contributor state — it is
        // not rebuildable from the WAL — so the old→new snapshot swap MUST be
        // all-or-nothing. The previous implementation issued individual
        // `delete()`s of every stale row followed by individual `put()`s of the
        // current cells; a crash or I/O error between the deletes and the puts
        // left the durable snapshot partially erased, silently losing community
        // contributions on a normal shutdown-checkpoint. We stage every
        // stale-key delete and every current-cell put into a single WriteBatch
        // and commit once, matching the cohort/co-engagement checkpoints, so the
        // backend applies the whole replacement atomically.
        let prefix = entity_tag_prefix(EntityId::new(0), Tag::Community);
        let mut batch = WriteBatch::new();

        // Stage deletion of any stale entries so a purged contributor does not
        // linger after the swap.
        for item in storage.scan_prefix(&prefix) {
            let (key, _) = item.map_err(TidalError::from)?;
            batch.delete(key);
        }

        // Stage the current snapshot.
        for cell in &self.cells {
            let (community, entity, type_id) = *cell.key();
            for ((user, writer, epoch), entry) in &cell.value().contributors {
                let suffix =
                    encode_contributor_suffix(community, *user, *epoch, entity, type_id, writer);
                let key = encode_key(EntityId::new(0), Tag::Community, &suffix);
                batch.put(key, serialize_entry(entity, type_id, entry));
            }
        }

        // Stage purge watermarks. These are NOT contributor rows, so they use the
        // watermark suffix codec (a sentinel `signal_type` discriminant the
        // contributor decoder rejects) and survive a restart independent of any
        // surviving contributor cells. Without this a community whose every
        // contributor was purged would lose its watermark, which queries surface.
        for w in &self.purge_watermarks {
            let key = encode_key(
                EntityId::new(0),
                Tag::Community,
                &encode_watermark_suffix(*w.key()),
            );
            batch.put(key, w.value().to_le_bytes().to_vec());
        }

        storage.write_batch(batch).map_err(TidalError::from)?;
        Ok(())
    }

    /// Restore per-contributor entries from a `Tag::Community` checkpoint.
    pub fn restore(&self, storage: &dyn StorageEngine) {
        let prefix = entity_tag_prefix(EntityId::new(0), Tag::Community);
        for (key, value) in storage.scan_prefix(&prefix).flatten() {
            // Strip the 10-byte [entity_id 8][NUL][tag] prefix to reach the suffix.
            let Some(suffix) = key.get(10..) else {
                continue;
            };
            // Watermark rows are shorter than the contributor fixed prefix and are
            // decoded first so they are not misread as torn contributor rows.
            if let Some(community) = decode_watermark_suffix(suffix) {
                if value.len() >= 8 {
                    let w = u64::from_le_bytes(value[..8].try_into().unwrap_or([0u8; 8]));
                    self.set_purge_watermark(community, w);
                }
                continue;
            }
            let Some(coords) = decode_contributor_suffix(suffix) else {
                continue;
            };
            // The entity/signal_type live in the key (authoritative for cell
            // placement); the value also carries them and they must agree. If a
            // row is torn or mismatched we skip it rather than mis-bucket state.
            if let Ok((value_entity, value_type, entry)) = deserialize_entry(&value)
                && value_entity == coords.entity
                && value_type == coords.signal_type
            {
                let mut cell = self
                    .cells
                    .entry((coords.community, coords.entity, coords.signal_type))
                    .or_default();
                cell.contributors
                    .insert((coords.user, coords.writer, coords.epoch), entry);
                drop(cell);
            }
        }
    }

    /// Re-apply a deserialized [`PurgeTombstone`](super::tombstone::PurgeTombstone)
    /// against the restored ledger, making a purge idempotent across crash/replay.
    ///
    /// The tombstone is written durably *before* the in-memory aggregate is
    /// rematerialized, but the community checkpoint is only flushed on the
    /// lifecycle path — so a crash after the purge but before the next checkpoint
    /// would restore the *pre-purge* `Tag::Community` snapshot and silently
    /// resurrect the purged user's data. The on-open recovery path closes that
    /// window by scanning every `Tag::PurgeTombstone` row and replaying it here:
    /// it removes the user's contributions over the tombstone's epoch range and
    /// re-establishes the purge watermark, reaching the exact post-purge state.
    /// Replaying a tombstone whose purge already survived in the checkpoint is a
    /// no-op (purge is idempotent), so double application is safe.
    pub(crate) fn apply_purge_tombstone(&self, t: &super::tombstone::PurgeTombstone) {
        let _removed = self.purge_contributor(t.community_id, t.user_id, t.epoch_range());
        self.set_purge_watermark(t.community_id, t.watermark_ns);
    }

    /// Re-apply a deserialized [`AgentPurgeTombstone`](super::tombstone::AgentPurgeTombstone)
    /// against the restored ledger, making an agent-scope purge idempotent across
    /// crash/replay (the agent-path analogue of [`apply_purge_tombstone`](Self::apply_purge_tombstone)).
    ///
    /// The tombstone is written durably *before* `purge_writer` mutates the
    /// in-memory aggregate, but the community checkpoint is only flushed on the
    /// lifecycle/periodic path — so a crash after the purge but before the next
    /// checkpoint would restore the *pre-purge* snapshot and silently resurrect the
    /// revoked agent's contributions. On-open replay closes that window: it
    /// re-purges by writer and re-stamps the watermark. Replaying a purge that
    /// already survived in the checkpoint is a no-op (purge is idempotent).
    pub(crate) fn apply_agent_purge_tombstone(&self, t: &super::tombstone::AgentPurgeTombstone) {
        let _removed = self.purge_writer(t.community_id, &t.writer_agent);
        self.set_purge_watermark(t.community_id, t.watermark_ns);
    }

    /// A canonical, order-independent digest of a community's entire aggregate
    /// state at `now_ns`, for verifying rematerialization determinism (M9p3:
    /// "rebuilt aggregates identical across repeated replay").
    #[must_use]
    pub fn aggregate_digest(&self, community: CommunityId, now_ns: u64) -> u128 {
        // Collect (entity, type, summed_score_bits, summed_count) tuples, sorted,
        // then BLAKE3 the canonical byte image.
        let mut rows: Vec<(u64, u16, u64, u64)> = Vec::new();
        for cell in &self.cells {
            let (c, entity, type_id) = *cell.key();
            if c != community {
                continue;
            }
            let lambda = self
                .signal_lambdas
                .get(&type_id)
                .and_then(|v| v.first())
                .copied()
                .unwrap_or(0.0);
            // Sort the (key, entry) pairs we already hold; the keys came from this
            // same map under this read guard, so re-`get`ting each is redundant.
            let mut entries: Vec<(&ContributorKey, &EntitySignalEntry)> =
                cell.value().contributors.iter().collect();
            // Sort by the contributor key. The key borrows from the element, so this
            // uses `sort_unstable_by` (a borrowing `sort_by_key` closure cannot satisfy
            // the `FnMut(&T) -> K` lifetime); clippy's `unnecessary_sort_by` does not
            // fire here for that reason. `x.0` / `y.0` are `&ContributorKey`.
            entries.sort_unstable_by(|x, y| x.0.cmp(y.0));
            let mut score = 0.0;
            let mut count = 0u64;
            for (_k, e) in entries {
                score += e.hot.current_score(0, now_ns, lambda);
                count += e.warm.windowed_count(Window::AllTime, now_ns);
            }
            rows.push((entity.as_u64(), type_id.as_u16(), score.to_bits(), count));
        }
        rows.sort_unstable();
        let mut hasher = blake3::Hasher::new();
        for (e, t, s, c) in rows {
            hasher.update(&e.to_le_bytes());
            hasher.update(&t.to_le_bytes());
            hasher.update(&s.to_le_bytes());
            hasher.update(&c.to_le_bytes());
        }
        let h = hasher.finalize();
        // BLAKE3 always finalizes to 32 bytes, so the 16-byte truncation is
        // infallible; `expect` documents the invariant, it cannot fire at runtime.
        u128::from_le_bytes(h.as_bytes()[..16].try_into().expect("16 bytes"))
    }
}

// ── Checkpoint key codec ────────────────────────────────────────────────────

/// Fixed-length prefix of a contributor checkpoint suffix, in bytes:
/// `community 8 || user 8 || epoch 4 || entity 8 || signal_type 2`. The writer
/// tag (variable-length UTF-8) follows and runs to the end of the suffix.
const CONTRIB_SUFFIX_FIXED_LEN: usize = 8 + 8 + 4 + 8 + 2;

/// Length of a purge-watermark suffix: a single big-endian community id.
///
/// Deliberately shorter than [`CONTRIB_SUFFIX_FIXED_LEN`] so a watermark row is
/// unambiguously distinguishable from a (possibly torn) contributor row during
/// restore — the contributor decoder rejects anything shorter than its fixed
/// prefix, so a watermark suffix can never be mis-bucketed as a contribution.
const WATERMARK_SUFFIX_LEN: usize = 8;

/// Encode the durable suffix for a purge-watermark row: `community 8` (BE).
fn encode_watermark_suffix(community: CommunityId) -> Vec<u8> {
    community.as_u64().to_be_bytes().to_vec()
}

/// Decode a purge-watermark suffix, or `None` if it is not exactly the
/// watermark length (i.e. it is a contributor row).
fn decode_watermark_suffix(suffix: &[u8]) -> Option<CommunityId> {
    if suffix.len() != WATERMARK_SUFFIX_LEN {
        return None;
    }
    Some(CommunityId(u64::from_be_bytes(suffix.try_into().ok()?)))
}

/// The fully-decoded coordinates of one contributor checkpoint row.
struct ContributorCoords {
    community: CommunityId,
    user: UserId,
    epoch: u32,
    entity: EntityId,
    signal_type: SignalTypeId,
    writer: WriterTag,
}

/// Encode the durable storage-key suffix for one contributor cell entry.
///
/// Layout (all big-endian so keys sort meaningfully):
/// `community 8 || user 8 || epoch 4 || entity 8 || signal_type 2 || writer..`.
///
/// `entity` and `signal_type` are part of the key — not just the value — so that
/// a single `(community, user, epoch, writer)` contributor who touches multiple
/// `(entity, signal_type)` cells produces a *distinct* row per cell. Omitting
/// them would collide every such cell onto one key and silently drop all but the
/// last on checkpoint (the M0–M10 review BLOCKER this codec fixes). One codec is
/// used by both [`CommunityLedger::checkpoint`] and
/// [`CommunityLedger::restore`] so the two sides cannot drift.
fn encode_contributor_suffix(
    community: CommunityId,
    user: UserId,
    epoch: u32,
    entity: EntityId,
    signal_type: SignalTypeId,
    writer: &str,
) -> Vec<u8> {
    let mut suffix = Vec::with_capacity(CONTRIB_SUFFIX_FIXED_LEN + writer.len());
    suffix.extend_from_slice(&community.as_u64().to_be_bytes());
    suffix.extend_from_slice(&user.as_u64().to_be_bytes());
    suffix.extend_from_slice(&epoch.to_be_bytes());
    suffix.extend_from_slice(&entity.as_u64().to_be_bytes());
    suffix.extend_from_slice(&signal_type.as_u16().to_be_bytes());
    suffix.extend_from_slice(writer.as_bytes());
    suffix
}

/// Decode a contributor suffix produced by [`encode_contributor_suffix`].
///
/// Returns `None` if the suffix is shorter than the fixed prefix (a torn or
/// foreign row), in which case the caller skips it rather than mis-parsing.
fn decode_contributor_suffix(suffix: &[u8]) -> Option<ContributorCoords> {
    if suffix.len() < CONTRIB_SUFFIX_FIXED_LEN {
        return None;
    }
    // Slices are length-checked above, so every fixed-width field is in bounds;
    // `try_into` on an exact-length slice cannot fail, but we map it to `None`
    // rather than unwrap so a future layout edit can never panic on bad input.
    let community = CommunityId(u64::from_be_bytes(suffix[0..8].try_into().ok()?));
    let user = UserId(u64::from_be_bytes(suffix[8..16].try_into().ok()?));
    let epoch = u32::from_be_bytes(suffix[16..20].try_into().ok()?);
    let entity = EntityId::new(u64::from_be_bytes(suffix[20..28].try_into().ok()?));
    let signal_type = SignalTypeId::new(u16::from_be_bytes(suffix[28..30].try_into().ok()?));
    let writer = String::from_utf8_lossy(&suffix[CONTRIB_SUFFIX_FIXED_LEN..]).into_owned();
    Some(ContributorCoords {
        community,
        user,
        epoch,
        entity,
        signal_type,
        writer,
    })
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::time::Duration;

    use super::*;
    use crate::schema::{DecaySpec, EntityKind, SchemaBuilder, Timestamp};

    fn schema() -> Schema {
        let mut b = SchemaBuilder::new();
        let _ = b
            .signal(
                "like",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(7 * 24 * 3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        // A second signal type so checkpoint/restore round-trip tests can cover a
        // single contributor that spans multiple signal types (the BLOCKER case).
        let _ = b
            .signal(
                "save",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(30 * 24 * 3600),
                },
            )
            .windows(&[Window::AllTime])
            .velocity(false)
            .add();
        b.build().unwrap()
    }

    fn ledger() -> CommunityLedger {
        CommunityLedger::new(&schema())
    }

    #[test]
    fn aggregate_sums_contributors() {
        let l = ledger();
        let (c, item) = (CommunityId(1), EntityId::new(5));
        let tid = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();
        l.record(c, item, tid, UserId(1), "", 0, 1.0, ts);
        l.record(c, item, tid, UserId(2), "", 0, 1.0, ts);
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            2
        );
        assert_eq!(l.contributor_count(c), 2);
    }

    #[test]
    fn windowed_count_at_uses_the_supplied_clock() {
        // W35: `windowed_count_at` ages the warm tier against the *supplied*
        // `now_ns` (sharing one query clock with its siblings) instead of
        // re-sampling the wall clock internally. A bounded window read far in the
        // future drops contributions that have aged out of the window, while the
        // AllTime window keeps them — proving the passed clock is what's honored.
        let l = ledger();
        let (c, item) = (CommunityId(1), EntityId::new(5));
        let tid = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();
        l.record(c, item, tid, UserId(1), "", 0, 1.0, ts);
        l.record(c, item, tid, UserId(2), "", 0, 1.0, ts);

        // At the contribution instant, both are inside every window.
        assert_eq!(
            l.windowed_count_at(c, item, "like", Window::AllTime, ts)
                .unwrap(),
            2
        );
        assert_eq!(
            l.windowed_count_at(c, item, "like", Window::OneHour, ts)
                .unwrap(),
            2
        );

        // Two hours later (per the supplied clock), the 1h window has aged both
        // contributions out, but AllTime still counts them.
        let two_hours_later = ts + 2 * 3_600 * 1_000_000_000;
        assert_eq!(
            l.windowed_count_at(c, item, "like", Window::OneHour, two_hours_later)
                .unwrap(),
            0,
            "bounded window must decay against the supplied clock, not Timestamp::now()"
        );
        assert_eq!(
            l.windowed_count_at(c, item, "like", Window::AllTime, two_hours_later)
                .unwrap(),
            2
        );

        // The legacy wrapper still works (reads the real clock once).
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            2
        );
    }

    #[test]
    fn purge_removes_one_users_contributions() {
        let l = ledger();
        let (c, item) = (CommunityId(1), EntityId::new(5));
        let tid = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();
        l.record(c, item, tid, UserId(1), "", 0, 1.0, ts);
        l.record(c, item, tid, UserId(2), "", 0, 1.0, ts);

        let removed = l.purge_contributor(c, UserId(1), 0..=u32::MAX);
        assert_eq!(removed, 1);
        // Only user 2's contribution remains.
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            1
        );
    }

    #[test]
    fn purge_is_deterministic_and_idempotent() {
        let l = ledger();
        let (c, item) = (CommunityId(7), EntityId::new(3));
        let tid = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();
        // Multiple contributors, interleaved.
        for u in 1..=5u64 {
            l.record(c, item, tid, UserId(u), "", 0, 1.0, ts + u);
        }
        let now = ts + 1_000_000_000;
        let digest_before = l.aggregate_digest(c, now);

        // Purge user 3 once, capture digest.
        let _ = l.purge_contributor(c, UserId(3), 0..=0);
        let digest_after = l.aggregate_digest(c, now);
        assert_ne!(
            digest_before, digest_after,
            "purge must change the aggregate"
        );

        // Re-running the same purge is a no-op: identical digest (idempotent +
        // deterministic rematerialization).
        let _ = l.purge_contributor(c, UserId(3), 0..=0);
        assert_eq!(
            digest_after,
            l.aggregate_digest(c, now),
            "repeated purge must yield an identical aggregate"
        );
    }

    #[test]
    fn purge_respects_epoch_range() {
        let l = ledger();
        let (c, item) = (CommunityId(1), EntityId::new(5));
        let tid = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();
        // Same user contributed under epoch 0 and epoch 1.
        l.record(c, item, tid, UserId(1), "", 0, 1.0, ts);
        l.record(c, item, tid, UserId(1), "", 1, 1.0, ts);
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            2
        );

        // Purge only epoch 0.
        let removed = l.purge_contributor(c, UserId(1), 0..=0);
        assert_eq!(removed, 1);
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            1,
            "epoch-1 contribution must survive a 0..=0 purge"
        );
    }

    #[test]
    fn empty_cell_reads_none() {
        let l = ledger();
        let (c, item) = (CommunityId(1), EntityId::new(5));
        let tid = l.resolve_signal_type("like").unwrap();
        l.record(
            c,
            item,
            tid,
            UserId(1),
            "",
            0,
            1.0,
            Timestamp::now().as_nanos(),
        );
        let _ = l.purge_contributor(c, UserId(1), 0..=u32::MAX);
        let now = Timestamp::now().as_nanos();
        assert_eq!(l.decay_score_at(c, item, "like", 0, now).unwrap(), None);
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            0
        );
    }

    #[test]
    fn contributor_suffix_codec_roundtrips() {
        // Distinct (entity, signal_type) for the same contributor must encode to
        // distinct keys — the core of the BLOCKER fix.
        let a = encode_contributor_suffix(
            CommunityId(1),
            UserId(2),
            3,
            EntityId::new(10),
            SignalTypeId::new(0),
            "agent-x",
        );
        let b = encode_contributor_suffix(
            CommunityId(1),
            UserId(2),
            3,
            EntityId::new(11), // different entity
            SignalTypeId::new(0),
            "agent-x",
        );
        let c = encode_contributor_suffix(
            CommunityId(1),
            UserId(2),
            3,
            EntityId::new(10),
            SignalTypeId::new(1), // different signal type
            "agent-x",
        );
        assert_ne!(a, b, "different entity must yield a different key");
        assert_ne!(a, c, "different signal type must yield a different key");

        let coords = decode_contributor_suffix(&a).expect("decode ok");
        assert_eq!(coords.community, CommunityId(1));
        assert_eq!(coords.user, UserId(2));
        assert_eq!(coords.epoch, 3);
        assert_eq!(coords.entity, EntityId::new(10));
        assert_eq!(coords.signal_type, SignalTypeId::new(0));
        assert_eq!(coords.writer, "agent-x");

        // An empty writer (direct user write) round-trips too.
        let direct = encode_contributor_suffix(
            CommunityId(9),
            UserId(8),
            0,
            EntityId::new(7),
            SignalTypeId::new(0),
            "",
        );
        let d = decode_contributor_suffix(&direct).expect("decode ok");
        assert_eq!(d.writer, "");
        assert_eq!(d.entity, EntityId::new(7));

        // A suffix shorter than the fixed prefix is rejected, not mis-parsed.
        assert!(decode_contributor_suffix(&[0u8; CONTRIB_SUFFIX_FIXED_LEN - 1]).is_none());
    }

    #[test]
    fn checkpoint_restore_preserves_multi_entity_multi_signal_contributor() {
        // BLOCKER regression: ONE contributor spanning >=2 entities AND >=2
        // signal types. Before the key carried (entity, signal_type), every cell
        // for this contributor collided on a single storage key and all but one
        // was silently lost on restore.
        use crate::storage::InMemoryBackend;

        let storage = InMemoryBackend::new();
        let l = ledger();
        let c = CommunityId(1);
        let like = l.resolve_signal_type("like").unwrap();
        let save = l.resolve_signal_type("save").unwrap();
        let ts = Timestamp::now().as_nanos();

        // Same contributor (UserId(1), writer "agent", epoch 0) touches two items
        // across two signal types — four distinct cells.
        let item_a = EntityId::new(100);
        let item_b = EntityId::new(200);
        for (item, tid) in [
            (item_a, like),
            (item_a, save),
            (item_b, like),
            (item_b, save),
        ] {
            l.record(c, item, tid, UserId(1), "agent", 0, 1.0, ts);
        }

        // All four aggregates are present before checkpoint.
        for (item, name) in [
            (item_a, "like"),
            (item_a, "save"),
            (item_b, "like"),
            (item_b, "save"),
        ] {
            assert_eq!(
                l.windowed_count(c, item, name, Window::AllTime).unwrap(),
                1,
                "pre-checkpoint aggregate missing for {item:?}/{name}"
            );
        }

        l.checkpoint(&storage).unwrap();

        // Restore into a fresh ledger and assert every aggregate survived.
        let restored = ledger();
        restored.restore(&storage);
        for (item, name) in [
            (item_a, "like"),
            (item_a, "save"),
            (item_b, "like"),
            (item_b, "save"),
        ] {
            assert_eq!(
                restored
                    .windowed_count(c, item, name, Window::AllTime)
                    .unwrap(),
                1,
                "checkpoint/restore LOST the {item:?}/{name} aggregate (key collision)"
            );
        }
        // The digest of the restored ledger matches the original — nothing dropped.
        let now = ts + 1_000_000_000;
        assert_eq!(
            l.aggregate_digest(c, now),
            restored.aggregate_digest(c, now),
            "restored aggregate digest must equal the original"
        );
    }

    /// Count the durable `Tag::Community` rows in a backend — i.e. how many
    /// contributor cells the checkpoint actually persisted.
    fn durable_community_row_count(storage: &crate::storage::InMemoryBackend) -> usize {
        let prefix = entity_tag_prefix(EntityId::new(0), Tag::Community);
        storage.scan_prefix(&prefix).flatten().count()
    }

    /// Total number of in-memory contributor cells across every community cell.
    fn in_memory_contributor_count(l: &CommunityLedger) -> usize {
        l.cells.iter().map(|c| c.value().contributors.len()).sum()
    }

    #[test]
    fn checkpoint_persists_exactly_the_in_memory_cells() {
        // The durable snapshot must contain one row per in-memory contributor
        // cell — no more, no fewer. This is the all-or-nothing-swap invariant:
        // every cell is persisted and nothing stale lingers.
        use crate::storage::InMemoryBackend;

        let storage = InMemoryBackend::new();
        let l = ledger();
        let c = CommunityId(1);
        let like = l.resolve_signal_type("like").unwrap();
        let save = l.resolve_signal_type("save").unwrap();
        let ts = Timestamp::now().as_nanos();

        // Two contributors across two items and two signal types.
        for (user, item, tid) in [
            (UserId(1), EntityId::new(10), like),
            (UserId(1), EntityId::new(20), save),
            (UserId(2), EntityId::new(10), like),
        ] {
            l.record(c, item, tid, user, "agent", 0, 1.0, ts);
        }

        l.checkpoint(&storage).unwrap();

        let durable = durable_community_row_count(&storage);
        let in_mem = in_memory_contributor_count(&l);
        assert_eq!(
            durable, in_mem,
            "durable rows ({durable}) must exactly equal in-memory cells ({in_mem})"
        );
        assert_eq!(durable, 3, "expected 3 contributor rows persisted");
    }

    #[test]
    fn recheckpoint_after_purge_replaces_snapshot_atomically() {
        // After a purge shrinks the in-memory state, a re-checkpoint must leave
        // the durable snapshot with *exactly* the surviving cells — no stale
        // rows for the purged contributor. Because the swap is a single atomic
        // WriteBatch (stale deletes + current puts), the durable row count
        // always matches the in-memory cell count, never the pre-purge count.
        use crate::storage::InMemoryBackend;

        let storage = InMemoryBackend::new();
        let l = ledger();
        let c = CommunityId(1);
        let like = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();

        // Three contributors, then checkpoint.
        for u in 1..=3u64 {
            l.record(c, EntityId::new(5), like, UserId(u), "", 0, 1.0, ts);
        }
        l.checkpoint(&storage).unwrap();
        assert_eq!(durable_community_row_count(&storage), 3);

        // Purge user 2, then re-checkpoint. The durable snapshot must shed the
        // purged row and hold only the two survivors.
        let removed = l.purge_contributor(c, UserId(2), 0..=u32::MAX);
        assert_eq!(removed, 1);
        l.checkpoint(&storage).unwrap();

        let durable = durable_community_row_count(&storage);
        assert_eq!(
            durable,
            in_memory_contributor_count(&l),
            "re-checkpoint must leave exactly the surviving cells"
        );
        assert_eq!(
            durable, 2,
            "purged contributor's stale row must not linger after re-checkpoint"
        );

        // And a fresh restore reflects exactly the post-purge state.
        let restored = ledger();
        restored.restore(&storage);
        assert_eq!(
            restored
                .windowed_count(c, EntityId::new(5), "like", Window::AllTime)
                .unwrap(),
            2,
            "restored aggregate must reflect the purge, not the pre-purge snapshot"
        );
    }

    #[test]
    fn purge_writer_empty_agent_removes_nothing() {
        // CRITICAL regression: an empty writer_agent (the direct-user-write
        // sentinel) must purge NOTHING, not every direct contribution.
        let l = ledger();
        let (c, item) = (CommunityId(1), EntityId::new(5));
        let tid = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();
        // Two direct user writes (writer_agent = "") and one agent write.
        l.record(c, item, tid, UserId(1), "", 0, 1.0, ts);
        l.record(c, item, tid, UserId(2), "", 0, 1.0, ts);
        l.record(c, item, tid, UserId(3), "agent-x", 0, 1.0, ts);
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            3
        );

        // Purging the empty agent removes 0 and leaves all direct writes intact.
        let removed = l.purge_writer(c, "");
        assert_eq!(removed, 0, "empty writer_agent must purge nothing");
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            3,
            "direct user writes must survive an empty-agent purge"
        );

        // A real agent id still purges only that agent's contribution.
        let removed = l.purge_writer(c, "agent-x");
        assert_eq!(removed, 1);
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            2
        );
    }

    #[test]
    fn persist_cell_durably_writes_one_row_and_restores() {
        // CRITICAL regression: the per-accept path must persist ONLY the touched
        // cell (incremental, O(1)) — not re-checkpoint the whole keyspace — yet
        // the row must still round-trip through restore.
        use crate::storage::InMemoryBackend;

        let storage = InMemoryBackend::new();
        let l = ledger();
        let c = CommunityId(1);
        let like = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();

        // Record two contributors and persist each cell incrementally.
        l.record(c, EntityId::new(10), like, UserId(1), "agent", 0, 1.0, ts);
        l.persist_cell(&storage, c, EntityId::new(10), like, UserId(1), "agent", 0)
            .unwrap();
        l.record(c, EntityId::new(20), like, UserId(2), "", 0, 1.0, ts);
        l.persist_cell(&storage, c, EntityId::new(20), like, UserId(2), "", 0)
            .unwrap();

        // Exactly two durable rows — no full-keyspace rewrite.
        assert_eq!(durable_community_row_count(&storage), 2);

        // Restore into a fresh ledger reproduces both aggregates.
        let restored = ledger();
        restored.restore(&storage);
        assert_eq!(
            restored
                .windowed_count(c, EntityId::new(10), "like", Window::AllTime)
                .unwrap(),
            1
        );
        assert_eq!(
            restored
                .windowed_count(c, EntityId::new(20), "like", Window::AllTime)
                .unwrap(),
            1
        );
    }

    /// Storage spy that delegates to an [`InMemoryBackend`] but counts how many
    /// times [`StorageEngine::flush`] is invoked, so a test can assert the
    /// per-accept path actually issues the durability barrier.
    struct FlushCountingBackend {
        inner: crate::storage::InMemoryBackend,
        flushes: std::sync::atomic::AtomicUsize,
    }

    impl FlushCountingBackend {
        fn new() -> Self {
            Self {
                inner: crate::storage::InMemoryBackend::new(),
                flushes: std::sync::atomic::AtomicUsize::new(0),
            }
        }
        fn flush_count(&self) -> usize {
            self.flushes.load(std::sync::atomic::Ordering::SeqCst)
        }
    }

    impl StorageEngine for FlushCountingBackend {
        fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, crate::storage::StorageError> {
            self.inner.get(key)
        }
        fn put(&self, key: &[u8], value: &[u8]) -> Result<(), crate::storage::StorageError> {
            self.inner.put(key, value)
        }
        fn delete(&self, key: &[u8]) -> Result<(), crate::storage::StorageError> {
            self.inner.delete(key)
        }
        fn scan_prefix(&self, prefix: &[u8]) -> crate::storage::iterator::PrefixIterator<'_> {
            self.inner.scan_prefix(prefix)
        }
        fn write_batch(&self, batch: WriteBatch) -> Result<(), crate::storage::StorageError> {
            self.inner.write_batch(batch)
        }
        fn flush(&self) -> Result<(), crate::storage::StorageError> {
            self.flushes
                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
            self.inner.flush()
        }
    }

    #[test]
    fn persist_cell_fsyncs_so_the_zero_loss_claim_holds() {
        // CRITICAL regression: the community ledger is the SOLE non-WAL-rebuildable
        // durable copy of per-contributor identity, and `signal_for_community`
        // promises Ok(true) means the contribution is durable. A bare write_batch
        // is committed-not-durable, so persist_cell MUST pair it with flush()
        // (fsync) before returning. This pins that the barrier is issued.
        let storage = FlushCountingBackend::new();
        let l = ledger();
        let c = CommunityId(1);
        let like = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();

        l.record(c, EntityId::new(10), like, UserId(1), "agent", 0, 1.0, ts);
        l.persist_cell(&storage, c, EntityId::new(10), like, UserId(1), "agent", 0)
            .unwrap();

        assert_eq!(
            storage.flush_count(),
            1,
            "persist_cell must fsync (flush) the contributor row, not leave it \
             committed-not-durable — the WAL cannot rebuild this state"
        );

        // And the row genuinely round-trips through restore.
        let restored = ledger();
        restored.restore(&storage.inner);
        assert_eq!(
            restored
                .windowed_count(c, EntityId::new(10), "like", Window::AllTime)
                .unwrap(),
            1
        );
    }

    #[test]
    fn apply_purge_tombstone_purges_and_sets_watermark() {
        use crate::governance::tombstone::PurgeTombstone;

        let l = ledger();
        let (c, item) = (CommunityId(1), EntityId::new(5));
        let tid = l.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();
        l.record(c, item, tid, UserId(1), "", 0, 1.0, ts);
        l.record(c, item, tid, UserId(2), "", 0, 1.0, ts);
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            2
        );
        assert!(l.purge_watermark(c).is_none());

        // Applying user 1's tombstone removes their contribution and stamps the
        // watermark — the same outcome the on-open replay path must reach.
        let t = PurgeTombstone::new(UserId(1), c, 0, u32::MAX, 777);
        l.apply_purge_tombstone(&t);
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            1,
            "tombstone replay must remove the purged user's contribution"
        );
        assert_eq!(l.purge_watermark(c), Some(777));

        // Replaying the same tombstone is idempotent (crash mid-replay safe).
        l.apply_purge_tombstone(&t);
        assert_eq!(
            l.windowed_count(c, item, "like", Window::AllTime).unwrap(),
            1,
            "double tombstone replay must be a no-op"
        );
        assert_eq!(l.purge_watermark(c), Some(777));
    }

    #[test]
    fn tombstone_replay_recovers_purge_lost_to_a_crash() {
        // Simulate the crash-before-checkpoint window: a purge happens in memory,
        // but the durable Tag::Community checkpoint still reflects the PRE-purge
        // state (it was flushed before the purge). On reopen we restore that
        // stale checkpoint and then replay the durable tombstone to reach the
        // correct post-purge state.
        use crate::governance::tombstone::PurgeTombstone;
        use crate::storage::InMemoryBackend;

        let storage = InMemoryBackend::new();
        let pre = ledger();
        let (c, item) = (CommunityId(1), EntityId::new(5));
        let tid = pre.resolve_signal_type("like").unwrap();
        let ts = Timestamp::now().as_nanos();
        pre.record(c, item, tid, UserId(1), "", 0, 1.0, ts);
        pre.record(c, item, tid, UserId(2), "", 0, 1.0, ts);
        // Durable checkpoint captures BOTH contributors (the pre-purge snapshot).
        pre.checkpoint(&storage).unwrap();

        // The user's purge tombstone is durable, but no post-purge checkpoint ran
        // before the (simulated) crash.
        let t = PurgeTombstone::new(UserId(1), c, 0, u32::MAX, 999);

        // Reopen: restore the stale checkpoint, then replay the tombstone.
        let reopened = ledger();
        reopened.restore(&storage);
        assert_eq!(
            reopened
                .windowed_count(c, item, "like", Window::AllTime)
                .unwrap(),
            2,
            "stale checkpoint resurrects both contributors before replay"
        );
        reopened.apply_purge_tombstone(&t);
        assert_eq!(
            reopened
                .windowed_count(c, item, "like", Window::AllTime)
                .unwrap(),
            1,
            "tombstone replay must re-purge the crash-resurrected contribution"
        );
        assert_eq!(reopened.purge_watermark(c), Some(999));
    }
}
