Skip to main content

coven_database/
authorship.rs

1//! Another member's authorship of the creations and cells a snapshot carries
2//! (§9.6, §16.1, D7, D9, D11).
3//!
4//! A snapshot's own signature covers what its author's member wrote. For every
5//! owned row created, or cell set, by another member's write, the snapshot
6//! carries that write's signed header and an opening of the needed leaves in
7//! the snapshot audience's part, so it cannot invent their authorship. A
8//! migration write's changes have no leaves; its authenticated header shows
9//! it is one. Openings need only leaf hashes: a device that received a part
10//! whole keeps them and can open any leaf again without the audience key.
11
12use crate::sqlite::DatabaseConnection;
13use crate::write_encoding::{decoded, encoded};
14use crate::write_schema::WriteSchema;
15use crate::DbError;
16use coven_crypto::AuthorshipHasher;
17use coven_format::authorship::{CellKind, KeptLeaf, Leaf, Multiproof, WriteEvidence};
18use coven_format::merge_fields;
19use coven_format::write::WriteDisposition;
20use coven_format::write_stream::WriteHeaderFrame;
21use coven_foundation::id_source::{KeyId, MemberId};
22use coven_merge::{Audience, ColumnValue, RowId, WriteId};
23use rusqlite::params;
24use std::collections::{BTreeMap, BTreeSet};
25
26/// What a loader needs from sync to authenticate another member's write.
27pub trait Authorship: Send + Sync {
28    /// Open the evidence's sealed header with the store key its prefix names,
29    /// and verify its header signature with the signing key of its device's
30    /// member, current in the header's recorded prefix (D9).
31    fn header(&self, evidence: &WriteEvidence) -> Result<WriteHeaderFrame, DbError>;
32
33    /// The keyed leaf hasher of an audience's part sealed with this key (D11).
34    fn hasher(&self, audience: &Audience, key: KeyId) -> Result<AuthorshipHasher, DbError>;
35}
36
37/// A write's signed header and every part root, retained from receipt or a
38/// loaded snapshot (D9).
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct HeaderEvidence {
41    /// The exact cleartext `kind | version | prefix` bytes.
42    pub prefix: Vec<u8>,
43    /// The sealed header chunk exactly as stored, with its length field.
44    pub header: Vec<u8>,
45    /// Every part's root, in header order.
46    pub roots: Vec<[u8; 32]>,
47    /// The header signature.
48    pub signature: [u8; coven_crypto::SIGNATURE_LEN],
49}
50
51/// A list of 32-byte hashes as coven's tables store it: the hashes end to
52/// end.
53fn hash_bytes(hashes: &[[u8; 32]]) -> Vec<u8> {
54    hashes.iter().flatten().copied().collect()
55}
56
57/// The hashes a stored list holds. One whose length isn't whole hashes is
58/// `DamagedDatabase` (§19.3).
59fn hash_list(bytes: &[u8]) -> Result<Vec<[u8; 32]>, DbError> {
60    let hashes = bytes.chunks_exact(32);
61    if !hashes.remainder().is_empty() {
62        return Err(DbError::DamagedDatabase);
63    }
64    Ok(hashes.map(|hash| hash.try_into().expect("a 32-byte chunk")).collect())
65}
66
67pub(crate) fn retain_header(
68    db: &DatabaseConnection,
69    write: WriteId,
70    evidence: &HeaderEvidence,
71) -> Result<(), DbError> {
72    let roots = hash_bytes(&evidence.roots);
73    db.internal_execute(
74        "INSERT INTO _coven_write_evidence(write_id,prefix,header,roots,signature) VALUES(?1,?2,?3,?4,?5) ON CONFLICT(write_id) DO NOTHING",
75        params![
76            encoded(merge_fields::encode_write_id(&write))?,
77            evidence.prefix,
78            evidence.header,
79            roots,
80            evidence.signature.as_slice()
81        ],
82    )?;
83    Ok(())
84}
85
86/// A write's stored header evidence. Roots that aren't whole hashes, or a
87/// signature of the wrong width, are `DamagedDatabase` (§19.3).
88fn header(db: &DatabaseConnection, write: WriteId) -> Result<Option<HeaderEvidence>, DbError> {
89    let damaged = |column| {
90        crate::write_encoding::decode_failure(
91            column,
92            rusqlite::types::Type::Blob,
93            DbError::DamagedDatabase,
94        )
95    };
96    Ok(db
97        .query(
98            "SELECT prefix,header,roots,signature FROM _coven_write_evidence WHERE write_id=?1",
99            [encoded(merge_fields::encode_write_id(&write))?],
100            |r| {
101                let roots: Vec<u8> = r.get(2)?;
102                let signature: Vec<u8> = r.get(3)?;
103                Ok(HeaderEvidence {
104                    prefix: r.get(0)?,
105                    header: r.get(1)?,
106                    roots: hash_list(&roots).map_err(|_damaged| damaged(2))?,
107                    signature: signature.try_into().map_err(|_wrong_width| damaged(3))?,
108                })
109            },
110        )?
111        .pop())
112}
113
114/// Keep a received part's leaf hashes, so its leaves can be opened later.
115pub(crate) fn retain_leaves(
116    db: &DatabaseConnection,
117    write: WriteId,
118    audience: &Audience,
119    hashes: &[[u8; 32]],
120) -> Result<(), DbError> {
121    let hashes = hash_bytes(hashes);
122    db.internal_execute(
123        "INSERT INTO _coven_part_leaves(write_id,audience,hashes) VALUES(?1,?2,?3) ON CONFLICT(write_id,audience) DO NOTHING",
124        params![
125            encoded(merge_fields::encode_write_id(&write))?,
126            audience,
127            hashes
128        ],
129    )?;
130    Ok(())
131}
132
133/// Whether this device keeps the leaf hashes of `write`'s part in
134/// `audience`, having received it whole.
135pub(crate) fn holds_leaves(
136    db: &DatabaseConnection,
137    write: WriteId,
138    audience: &Audience,
139) -> Result<bool, DbError> {
140    db.query_row(
141        "SELECT EXISTS(SELECT 1 FROM _coven_part_leaves WHERE write_id=?1 AND audience=?2)",
142        params![encoded(merge_fields::encode_write_id(&write))?, audience],
143        |r| r.get(0),
144    )
145}
146
147/// The kept records of `write`'s part in `audience` are forgotten (§16.7):
148/// its leaf hashes go, and the write's header evidence once no part of it
149/// keeps leaves or an opening.
150pub(crate) fn forget_part(
151    db: &DatabaseConnection,
152    write: WriteId,
153    audience: &Audience,
154) -> Result<(), DbError> {
155    let id = encoded(merge_fields::encode_write_id(&write))?;
156    db.internal_execute(
157        "DELETE FROM _coven_part_leaves WHERE write_id=?1 AND audience=?2",
158        params![id, audience],
159    )?;
160    db.internal_execute(
161        "DELETE FROM _coven_write_evidence WHERE write_id=?1
162         AND NOT EXISTS(SELECT 1 FROM _coven_part_leaves WHERE write_id=?1)
163         AND NOT EXISTS(SELECT 1 FROM _coven_part_proofs WHERE write_id=?1)",
164        [id],
165    )?;
166    Ok(())
167}
168
169/// What the snapshot audience's rows need opened in one write.
170#[derive(Default)]
171struct Needs {
172    creations: BTreeSet<RowId>,
173    /// Row, current column, value and the row's present generation.
174    cells: Vec<(RowId, String, ColumnValue<coven_format::value::Value>, u64)>,
175}
176
177/// Every owned row this device holds came from a whole received part or a
178/// snapshot's opening of it, so its evidence is retained.
179fn missing(write: WriteId) -> DbError {
180    tracing::error!(?write, "another member's write lacks retained authorship evidence");
181    DbError::Invariant { what: "another member's write that set an owned row keeps its evidence" }
182}
183
184/// A retained write's plaintext header.
185fn written(
186    db: &DatabaseConnection,
187    write: WriteId,
188) -> Result<Option<coven_format::write::WriteHeader>, DbError> {
189    if let Some(frame) = crate::write_headers::read(db, write)? {
190        return Ok(Some(frame.header));
191    }
192    let header: Option<Vec<u8>> = db
193        .query(
194            "SELECT header FROM _coven_authored_parts WHERE write_id=?1 LIMIT 1",
195            [encoded(merge_fields::encode_write_id(&write))?],
196            |r| r.get(0),
197        )?
198        .pop();
199    header.map(|bytes| Ok(merge_fields::decode_write_header(&bytes)?)).transpose()
200}
201
202/// Who signed a snapshot: its device, and that device's member when known.
203/// The signature itself vouches for whatever they wrote (§16.2).
204#[derive(Clone, Copy, Debug)]
205pub(crate) struct Author {
206    pub(crate) device: coven_foundation::id_source::DeviceId,
207    pub(crate) member: Option<MemberId>,
208}
209
210/// Whether a member other than the snapshot's author made a change in
211/// `write`: the snapshot needs that write's evidence for it.
212fn by_another_member(
213    db: &DatabaseConnection,
214    author: Author,
215    write: WriteId,
216) -> Result<bool, DbError> {
217    Ok(write.device != author.device
218        && (author.member.is_none()
219            || crate::store_state_tables::device_member(db, write.device)? != author.member))
220}
221
222/// Whether this device keeps `write`'s header evidence, leaf hashes and
223/// openings: a snapshot it writes carries evidence only for another member's
224/// writes (D7.5), and its member never changes, so its own member's writes
225/// never need them here. While this device's member is unknown it keeps
226/// every other device's evidence, as [`by_another_member`] reads `None`.
227pub(crate) fn keeps_evidence(db: &DatabaseConnection, write: WriteId) -> Result<bool, DbError> {
228    let device = db
229        .local_device()
230        .ok_or(DbError::Invariant { what: "a writer connection observes this device" })?;
231    by_another_member(
232        db,
233        Author { device, member: crate::store_state_tables::device_member(db, device)? },
234        write,
235    )
236}
237
238/// Whether a leaf names this row, read through the write's schema version.
239fn same_row(
240    db: &DatabaseConnection,
241    version: u32,
242    current: u32,
243    leaf: &RowId,
244    row: &RowId,
245) -> Result<bool, DbError> {
246    Ok(leaf.key == row.key
247        && leaf.audience == row.audience
248        && crate::source_names::table(db, version, &leaf.table, current)?.as_deref()
249            == Some(row.table.as_str()))
250}
251
252/// Whether a leaf proves this need. The leaves name rows and columns as the
253/// write did, before any later rename.
254fn proves(
255    db: &DatabaseConnection,
256    version: u32,
257    current: u32,
258    leaf: &Leaf,
259    needs: &Needs,
260    creators: &BTreeMap<RowId, Option<coven_format::write_sources::ChangeId>>,
261) -> Result<bool, DbError> {
262    match leaf {
263        Leaf::Creation { row, generation: 0, creator } => {
264            for needed in &needs.creations {
265                if same_row(db, version, current, row, needed)?
266                    && creators.get(needed) == Some(creator)
267                {
268                    return Ok(true);
269                }
270            }
271            Ok(false)
272        }
273        Leaf::Cell { row, generation, kind, column, value } => {
274            let incarnation = match kind {
275                CellKind::Insert => generation + 1,
276                CellKind::Update => *generation,
277            };
278            for (needed, name, needed_value, present) in &needs.cells {
279                if *present == incarnation
280                    && value == needed_value
281                    && same_row(db, version, current, row, needed)?
282                    && crate::source_names::column(
283                        db,
284                        version,
285                        &row.table,
286                        column,
287                        current,
288                        &needed.table,
289                    )?
290                    .as_deref()
291                        == Some(name.as_str())
292                {
293                    return Ok(true);
294                }
295            }
296            Ok(false)
297        }
298        _ => Ok(false),
299    }
300}
301
302/// The evidence a snapshot of `audience` written by `author` carries: every
303/// other member's write that created an owned row, or set a cell of a present
304/// owned row, with that part's needed leaves opened. The snapshot device's
305/// own writes are its signer's.
306pub(crate) fn evidence(
307    db: &DatabaseConnection,
308    schema: &WriteSchema,
309    audience: &Audience,
310    author: Author,
311) -> Result<Vec<WriteEvidence>, DbError> {
312    let mut needs = BTreeMap::<WriteId, Needs>::new();
313    let mut creators = BTreeMap::new();
314    let other = |write: WriteId| by_another_member(db, author, write);
315    crate::snapshot_view::creations::<DbError>(db, audience, |creation| {
316        let row = creation.row.clone();
317        if crate::ownership::owner_table(schema, &row.table) && other(creation.write)? {
318            let claim =
319                (creation.creator.write != creation.write).then(|| creation.creator.clone());
320            creators.insert(row.clone(), claim);
321            needs.entry(creation.write).or_default().creations.insert(row);
322        }
323        Ok(())
324    })?;
325    crate::snapshot_view::rows(db, schema, audience, |merged| {
326        let state = merged.state;
327        let row = state.row();
328        if crate::ownership::owner_table(schema, &row.table) && state.present() {
329            for (column, cell) in state.cells() {
330                if other(cell.write)? {
331                    needs.entry(cell.write).or_default().cells.push((
332                        row.clone(),
333                        column.clone(),
334                        cell.value.clone(),
335                        state.generation(),
336                    ));
337                }
338            }
339        }
340        Ok::<_, DbError>(())
341    })?;
342    let current = db.schema_version()?;
343    let mut evidence = Vec::new();
344    for (write, needs) in needs {
345        let original = header(db, write)?.ok_or(missing(write))?;
346        let plain = written(db, write)?.ok_or(missing(write))?;
347        let proof = if plain.disposition == WriteDisposition::Migration {
348            Multiproof { leaves: 0, revealed: Vec::new(), hidden: Vec::new() }
349        } else {
350            open(db, write, audience, plain.schema_version, current, &needs, &creators)?
351        };
352        evidence.push(WriteEvidence {
353            write,
354            prefix: original.prefix,
355            header: original.header,
356            roots: original.roots,
357            signature: original.signature,
358            proof,
359        });
360    }
361    Ok(evidence)
362}
363
364/// Open the needed leaves of one part: from its kept records and retained
365/// leaf hashes when held whole, or pruning a loaded snapshot's authenticated
366/// openings. A cell kept as a digest opens as the needed cell whose value it
367/// digests (D7.3).
368fn open(
369    db: &DatabaseConnection,
370    write: WriteId,
371    audience: &Audience,
372    version: u32,
373    current: u32,
374    needs: &Needs,
375    creators: &BTreeMap<RowId, Option<coven_format::write_sources::ChangeId>>,
376) -> Result<Multiproof, DbError> {
377    let id = encoded(merge_fields::encode_write_id(&write))?;
378    let hashes: Option<Vec<u8>> = db.query_row(
379        "SELECT (SELECT hashes FROM _coven_part_leaves WHERE write_id=?1 AND audience=?2)",
380        params![id, audience],
381        |r| r.get(0),
382    )?;
383    if let Some(hashes) = hashes {
384        let hashes = hash_list(&hashes)?;
385        let mut leaves = Vec::new();
386        db.for_each::<_, DbError>(
387            "SELECT record FROM _coven_authored_changes WHERE write_id=?1 AND audience=?2 ORDER BY table_name,key",
388            params![id, audience],
389            |r| {
390                let record = coven_format::snapshot_rows::KeptRecord::decode(
391                    &r.get::<_, Vec<u8>>(0)?,
392                )?;
393                leaves.extend(coven_format::authorship::kept_leaves(&record));
394                Ok(())
395            },
396        )?;
397        if leaves.len() != hashes.len() {
398            return Err(DbError::DamagedDatabase);
399        }
400        let mut opened = Vec::with_capacity(leaves.len());
401        for leaf in &leaves {
402            opened.push(opening(db, version, current, leaf, needs, creators)?);
403        }
404        return Ok(Multiproof::from_hashes(&hashes, opened));
405    }
406    let (proof, hashes) = db
407        .query(
408            "SELECT proof,hashes FROM _coven_part_proofs WHERE write_id=?1 AND audience=?2",
409            params![id, audience],
410            |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, Vec<u8>>(1)?)),
411        )?
412        .pop()
413        .ok_or_else(|| missing(write))?;
414    let proof = decoded(merge_fields::decode_multiproof(&proof))?;
415    let hashes = hash_list(&hashes)?;
416    let keep = proof
417        .opened()?
418        .iter()
419        .map(|leaf| proves(db, version, current, leaf, needs, creators))
420        .collect::<Result<Vec<_>, _>>()?;
421    Ok(proof.retain_opened(&hashes, &keep))
422}
423
424/// The opening of one kept leaf, if it proves a need: a leaf kept whole as
425/// it is, a cell kept as a digest as each needed cell its value matches.
426fn opening(
427    db: &DatabaseConnection,
428    version: u32,
429    current: u32,
430    leaf: &KeptLeaf,
431    needs: &Needs,
432    creators: &BTreeMap<RowId, Option<coven_format::write_sources::ChangeId>>,
433) -> Result<Option<Vec<u8>>, DbError> {
434    let candidates = match leaf {
435        KeptLeaf::Known(leaf) => vec![leaf.clone()],
436        KeptLeaf::Digest { .. } => {
437            let mut candidates = Vec::new();
438            for (_, _, value, _) in &needs.cells {
439                candidates.extend(leaf.with_value(value)?);
440            }
441            candidates
442        }
443        KeptLeaf::Step => Vec::new(),
444    };
445    for candidate in candidates {
446        if proves(db, version, current, &candidate, needs, creators)? {
447            return Ok(Some(candidate.encode()?));
448        }
449    }
450    Ok(None)
451}
452
453/// Authenticate one write's evidence in a loaded snapshot of `audience`: its
454/// header signature, then its opening against that part's signed root. Keep
455/// both for snapshots this device writes later.
456pub(crate) struct AuthenticatedEvidence {
457    pub(crate) opened: WriteHeaderFrame,
458    pub(crate) leaves: Vec<Leaf>,
459    pub(crate) hashes: Vec<[u8; 32]>,
460    pub(crate) part: bool,
461}
462
463pub(crate) fn authenticate(
464    authorship: &dyn Authorship,
465    audience: &Audience,
466    evidence: &WriteEvidence,
467) -> Result<AuthenticatedEvidence, DbError> {
468    let opened = authorship.header(evidence)?;
469    if opened.header.position != evidence.write {
470        return Err(crate::snapshot_error::invalid("write evidence names another write"));
471    }
472    let prefix = coven_format::sealed_write::WriteObjectPrefix::decode(&evidence.prefix)?;
473    let part = opened.parts.iter().position(|part| part.audience == *audience);
474    let (leaves, hashes) = match part {
475        Some(index) => {
476            let hasher = authorship.hasher(audience, prefix.part_keys[index])?;
477            let (root, hashes) = evidence.proof.root_with_leaf_hashes(&hasher)?;
478            if root != evidence.roots[index] {
479                return Err(crate::snapshot_error::invalid(
480                    "write evidence opening differs from its signed root",
481                ));
482            }
483            (evidence.proof.opened()?, hashes)
484        }
485        None if evidence.proof.leaves == 0 => (Vec::new(), Vec::new()),
486        None => {
487            return Err(crate::snapshot_error::invalid(
488                "write evidence opens a part the write does not have",
489            ))
490        }
491    };
492    Ok(AuthenticatedEvidence { opened, leaves, hashes, part: part.is_some() })
493}
494
495/// Retain already authenticated evidence for snapshots this device writes
496/// later: the plaintext header always, the header evidence and opening only
497/// for another member's write ([`keeps_evidence`]).
498pub(crate) fn load(
499    db: &DatabaseConnection,
500    audience: &Audience,
501    evidence: &WriteEvidence,
502    verified: &AuthenticatedEvidence,
503) -> Result<(), DbError> {
504    crate::write_headers::retain(db, &verified.opened)?;
505    if !keeps_evidence(db, evidence.write)? {
506        return Ok(());
507    }
508    retain_header(
509        db,
510        evidence.write,
511        &HeaderEvidence {
512            prefix: evidence.prefix.clone(),
513            header: evidence.header.clone(),
514            roots: evidence.roots.clone(),
515            signature: evidence.signature,
516        },
517    )?;
518    if verified.part {
519        let hashes = hash_bytes(&verified.hashes);
520        db.internal_execute(
521            "INSERT INTO _coven_part_proofs(write_id,audience,proof,hashes) VALUES(?1,?2,?3,?4) ON CONFLICT(write_id,audience) DO UPDATE SET proof=excluded.proof,hashes=excluded.hashes",
522            params![
523                encoded(merge_fields::encode_write_id(&evidence.write))?,
524                audience,
525                encoded(merge_fields::encode_multiproof(&evidence.proof))?,
526                hashes
527            ],
528        )?;
529    }
530    Ok(())
531}
532
533/// Refuse a loaded snapshot of `audience` by `author` that names another
534/// member as creating an owned row, or setting one of its present cells,
535/// without that write's authenticated opening (§9.6, §16.2).
536pub(crate) fn check(
537    db: &DatabaseConnection,
538    schema: &WriteSchema,
539    audience: &Audience,
540    author: Author,
541    authenticated: &BTreeMap<WriteId, AuthenticatedEvidence>,
542) -> Result<(), DbError> {
543    let current = db.schema_version()?;
544    let refused = || {
545        crate::snapshot_error::invalid(
546            "snapshot names another member's authorship without its write's evidence",
547        )
548    };
549    let other = |write: WriteId| by_another_member(db, author, write);
550    let mut needs = BTreeMap::<WriteId, Needs>::new();
551    let mut creators = BTreeMap::new();
552    crate::ownership::visit::<DbError>(db, audience, |row, creation| {
553        if crate::ownership::owner_table(schema, &row.table) && other(creation.write)? {
554            let claim =
555                (creation.creator.write != creation.write).then(|| creation.creator.clone());
556            creators.insert(row.clone(), claim);
557            needs.entry(creation.write).or_default().creations.insert(row);
558        }
559        Ok(())
560    })?;
561    // The loaded rows are still the snapshot's staged values.
562    let loaded = BTreeSet::from([audience.clone()]);
563    let store = crate::merge_store::MergeStore::from_snapshot(db, &schema.schema, &loaded);
564    for declaration in schema.declarations.iter().filter(|d| d.owner_only) {
565        let rows = db.query(
566            "SELECT DISTINCT table_name,key,audience FROM _coven_rows WHERE audience=?1 AND table_name=?2",
567            params![audience, declaration.name],
568            crate::row_queries::read_identity,
569        )?;
570        for row in rows {
571            let state = store.row_without_losses(&row)?.state;
572            if !state.present() {
573                continue;
574            }
575            for (column, cell) in state.cells() {
576                if other(cell.write)? {
577                    needs.entry(cell.write).or_default().cells.push((
578                        row.clone(),
579                        column.clone(),
580                        cell.value.clone(),
581                        state.generation(),
582                    ));
583                }
584            }
585        }
586    }
587    for (write, needs) in needs {
588        let Some(verified) = authenticated.get(&write) else {
589            return Err(refused());
590        };
591        if verified.opened.header.disposition == WriteDisposition::Migration {
592            continue;
593        }
594        let version = verified.opened.header.schema_version;
595        let leaves = &verified.leaves;
596        for row in &needs.creations {
597            let single = Needs { creations: BTreeSet::from([row.clone()]), cells: Vec::new() };
598            let mut found = false;
599            for leaf in leaves {
600                found |= proves(db, version, current, leaf, &single, &creators)?;
601            }
602            if !found {
603                return Err(refused());
604            }
605        }
606        for cell in &needs.cells {
607            let single = Needs { creations: BTreeSet::new(), cells: vec![cell.clone()] };
608            let mut found = false;
609            for leaf in leaves {
610                found |= proves(db, version, current, leaf, &single, &creators)?;
611            }
612            if !found {
613                return Err(refused());
614            }
615        }
616    }
617    Ok(())
618}
619
620/// Hash and check a received part's leaves against its signed root, keeping
621/// the hashes for later openings.
622pub(crate) struct PartRoot {
623    hasher: AuthorshipHasher,
624    root: [u8; 32],
625    hashes: Vec<[u8; 32]>,
626}
627
628impl PartRoot {
629    pub(crate) fn new(hasher: AuthorshipHasher, root: [u8; 32]) -> Self {
630        Self { hasher, root, hashes: Vec::new() }
631    }
632
633    /// Add one record's leaves, in stream order.
634    pub(crate) fn record(
635        &mut self,
636        record: coven_format::write::PartRecord<'_>,
637    ) -> Result<(), DbError> {
638        for leaf in coven_format::authorship::record_leaves(record) {
639            self.hashes.push(self.hasher.leaf(&leaf.encode()?));
640        }
641        Ok(())
642    }
643
644    /// Refuse a part whose leaves do not reproduce its signed root.
645    pub(crate) fn finish(self) -> Result<Vec<[u8; 32]>, DbError> {
646        if coven_format::authorship::root_of_hashes(&self.hashes) != self.root {
647            return Err(crate::snapshot_error::invalid(
648                "write part differs from its signed authorship root",
649            ));
650        }
651        Ok(self.hashes)
652    }
653}
654
655#[cfg(test)]
656#[path = "authorship_tests.rs"]
657pub(crate) mod tests;