Skip to main content

agora_agentkit/
govlog.rs

1//! Governance log attestation: the envelope the server signs at insert and
2//! any client can verify.
3//!
4//! Every entry commits to its own fields and to the hash of the entry before
5//! it, so the log is a chain: change or remove any entry and every later
6//! link stops verifying. The envelope (version 1) is
7//!
8//! ```text
9//! data_hash  = SHA-256( canonical_json(data) )
10//! preimage   = {"agora_governance_log":1,"id":…,"entry_type":…,
11//!               "created_at":<unix micros>,"prev_hash":<hex|null>,
12//!               "data_hash":<hex>}
13//! entry_hash = SHA-256( preimage )
14//! signature  = crypto::sign( key, entry_hash, signed_at unix seconds )
15//! ```
16//!
17//! What is *not* in the envelope, on purpose: `tags` (an index the Clerk may
18//! revise), `chain_seq` (an index; the `prev_hash` links prove order), and
19//! anything derived such as the precedent summary. `signed_at` is bound by
20//! the signature rather than the hash, so a retroactive attestation — an
21//! entry signed long after it was recorded — is visible as such and cannot
22//! be quietly back-dated. See [`is_retroactive`].
23//!
24//! Redaction is not a chain break. The original `entry_hash` stays on the
25//! row so later links still verify; a signed amendment entry names what
26//! changed. [`EntryVerdict::content_matches`] is the check that notices.
27
28use crate::crypto::{self, Signature, SigningKey, VerifyingKey};
29use crate::enums::GovernanceLogEntryType;
30use crate::ids::GovernanceLogId;
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35/// The envelope version this module produces and verifies
36pub const ENVELOPE_VERSION: u32 = 1;
37
38/// An attestation signed more than this long after its entry was recorded
39/// is [retroactive](is_retroactive)
40pub const RETROACTIVE_AFTER: chrono::Duration = chrono::Duration::seconds(60);
41
42// ---------------------------------------------------------------------------
43// Fixed-size hex newtypes
44// ---------------------------------------------------------------------------
45
46/// A hex string of the wrong length or alphabet for the type it was parsed
47/// into
48#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
49#[error("{type_name}: expected {expected} bytes of hex, got {got:?}")]
50pub struct HexLengthError {
51    pub type_name: &'static str,
52    pub expected: usize,
53    pub got: String,
54}
55
56macro_rules! hex_bytes {
57    ($(#[$meta:meta])* $name:ident, $len:expr) => {
58        $(#[$meta])*
59        #[derive(Clone, Copy, PartialEq, Eq, Hash)]
60        #[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
61        #[cfg_attr(feature = "sqlx", sqlx(transparent))]
62        pub struct $name([u8; $len]);
63
64        impl $name {
65            /// The raw bytes
66            pub fn as_bytes(&self) -> &[u8; $len] {
67                &self.0
68            }
69
70            /// Lowercase hex, as on the wire
71            pub fn to_hex(&self) -> String {
72                hex::encode(self.0)
73            }
74        }
75
76        impl From<[u8; $len]> for $name {
77            fn from(bytes: [u8; $len]) -> Self {
78                Self(bytes)
79            }
80        }
81
82        impl TryFrom<&[u8]> for $name {
83            type Error = HexLengthError;
84
85            fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
86                <[u8; $len]>::try_from(bytes).map(Self).map_err(|_| {
87                    HexLengthError {
88                        type_name: stringify!($name),
89                        expected: $len,
90                        got: hex::encode(bytes),
91                    }
92                })
93            }
94        }
95
96        impl TryFrom<Vec<u8>> for $name {
97            type Error = HexLengthError;
98
99            fn try_from(bytes: Vec<u8>) -> Result<Self, Self::Error> {
100                Self::try_from(bytes.as_slice())
101            }
102        }
103
104        impl std::str::FromStr for $name {
105            type Err = HexLengthError;
106
107            fn from_str(s: &str) -> Result<Self, Self::Err> {
108                let bytes = hex::decode(s.trim()).map_err(|_| HexLengthError {
109                    type_name: stringify!($name),
110                    expected: $len,
111                    got: s.to_string(),
112                })?;
113                Self::try_from(bytes.as_slice())
114            }
115        }
116
117        impl std::fmt::Display for $name {
118            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119                f.write_str(&self.to_hex())
120            }
121        }
122
123        impl std::fmt::Debug for $name {
124            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125                write!(f, "{}({})", stringify!($name), self.to_hex())
126            }
127        }
128
129        impl Serialize for $name {
130            fn serialize<S: serde::Serializer>(
131                &self,
132                s: S,
133            ) -> Result<S::Ok, S::Error> {
134                s.serialize_str(&self.to_hex())
135            }
136        }
137
138        impl<'de> Deserialize<'de> for $name {
139            fn deserialize<D: serde::Deserializer<'de>>(
140                d: D,
141            ) -> Result<Self, D::Error> {
142                let s = String::deserialize(d)?;
143                s.parse().map_err(serde::de::Error::custom)
144            }
145        }
146
147        // Hand-written for the same reason as every id newtype: a derived
148        // schema becomes a `$ref` into `$defs`, which the Claude.ai MCP
149        // connector mangles (see CLAUDE.md in the agora repo).
150        #[cfg(feature = "schemars")]
151        impl schemars::JsonSchema for $name {
152            fn inline_schema() -> bool {
153                true
154            }
155
156            fn schema_name() -> std::borrow::Cow<'static, str> {
157                std::borrow::Cow::Borrowed(stringify!($name))
158            }
159
160            fn schema_id() -> std::borrow::Cow<'static, str> {
161                std::borrow::Cow::Borrowed(concat!(
162                    module_path!(),
163                    "::",
164                    stringify!($name)
165                ))
166            }
167
168            fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
169                schemars::json_schema!({
170                    "type": "string",
171                    "pattern": format!("^[0-9a-f]{{{}}}$", $len * 2),
172                    "description": format!("{} bytes, lowercase hex", $len),
173                })
174            }
175        }
176    };
177}
178
179hex_bytes!(
180    /// A SHA-256 digest, hex on the wire
181    Sha256Hex,
182    32
183);
184
185hex_bytes!(
186    /// An Ed25519 signature, hex on the wire
187    SignatureHex,
188    64
189);
190
191hex_bytes!(
192    /// An Ed25519 public key, hex on the wire
193    PublicKeyHex,
194    32
195);
196
197impl From<Signature> for SignatureHex {
198    fn from(sig: Signature) -> Self {
199        Self(sig.to_bytes())
200    }
201}
202
203impl From<&SignatureHex> for Signature {
204    fn from(sig: &SignatureHex) -> Self {
205        Signature::from_bytes(&sig.0)
206    }
207}
208
209impl From<&VerifyingKey> for PublicKeyHex {
210    fn from(key: &VerifyingKey) -> Self {
211        Self(key.to_bytes())
212    }
213}
214
215impl PublicKeyHex {
216    /// The key, if the bytes are a valid curve point
217    pub fn to_verifying_key(
218        &self,
219    ) -> Result<VerifyingKey, ed25519_dalek::SignatureError> {
220        VerifyingKey::from_bytes(&self.0)
221    }
222}
223
224// ---------------------------------------------------------------------------
225// Canonical JSON and hashing
226// ---------------------------------------------------------------------------
227
228/// `value` as compact JSON with object keys sorted bytewise at every level.
229///
230/// `serde_json::to_vec` on a [`serde_json::Value`] is *not* canonical:
231/// with the `preserve_order` feature (on in every Agora workspace, off in
232/// this crate's own tests) objects serialize in insertion order, so the
233/// same value hashes differently depending on who built it. Strings and
234/// numbers use serde_json's own formatting, which is deterministic for a
235/// given value.
236pub fn canonical_json(value: &serde_json::Value) -> Vec<u8> {
237    let mut out = Vec::new();
238    write_canonical(value, &mut out);
239    out
240}
241
242fn write_canonical(value: &serde_json::Value, out: &mut Vec<u8>) {
243    use serde_json::Value;
244    match value {
245        Value::Null => out.extend_from_slice(b"null"),
246        Value::Bool(b) => {
247            out.extend_from_slice(if *b { b"true" } else { b"false" })
248        }
249        Value::Number(n) => serde_json::to_writer(&mut *out, n)
250            .expect("a number always serializes"),
251        Value::String(s) => serde_json::to_writer(&mut *out, s)
252            .expect("a string always serializes"),
253        Value::Array(items) => {
254            out.push(b'[');
255            for (i, item) in items.iter().enumerate() {
256                if i > 0 {
257                    out.push(b',');
258                }
259                write_canonical(item, out);
260            }
261            out.push(b']');
262        }
263        Value::Object(map) => {
264            let mut keys: Vec<&String> = map.keys().collect();
265            keys.sort_unstable();
266            out.push(b'{');
267            for (i, key) in keys.into_iter().enumerate() {
268                if i > 0 {
269                    out.push(b',');
270                }
271                serde_json::to_writer(&mut *out, key)
272                    .expect("a string always serializes");
273                out.push(b':');
274                write_canonical(&map[key], out);
275            }
276            out.push(b'}');
277        }
278    }
279}
280
281/// SHA-256 over [`canonical_json`]
282pub fn data_hash(data: &serde_json::Value) -> Sha256Hex {
283    Sha256Hex(Sha256::digest(canonical_json(data)).into())
284}
285
286/// The fields an entry's hash commits to
287///
288/// A struct rather than a [`serde_json::Value`] so the preimage serializes
289/// in declaration order whatever `preserve_order` says.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291pub struct Envelope {
292    /// Always [`ENVELOPE_VERSION`]
293    pub agora_governance_log: u32,
294    pub id: GovernanceLogId,
295    pub entry_type: GovernanceLogEntryType,
296    /// Unix microseconds — the precision Postgres stores
297    pub created_at: i64,
298    pub prev_hash: Option<Sha256Hex>,
299    pub data_hash: Sha256Hex,
300}
301
302impl Envelope {
303    /// The version-1 envelope for these fields
304    pub fn new(
305        id: GovernanceLogId,
306        entry_type: GovernanceLogEntryType,
307        created_at: DateTime<Utc>,
308        prev_hash: Option<Sha256Hex>,
309        data_hash: Sha256Hex,
310    ) -> Self {
311        Self {
312            agora_governance_log: ENVELOPE_VERSION,
313            id,
314            entry_type,
315            created_at: created_at.timestamp_micros(),
316            prev_hash,
317            data_hash,
318        }
319    }
320
321    /// `created_at` as a timestamp again
322    pub fn created_at(&self) -> DateTime<Utc> {
323        DateTime::from_timestamp_micros(self.created_at)
324            .expect("an Envelope only ever holds an in-range timestamp")
325    }
326
327    /// The bytes that are hashed
328    pub fn preimage(&self) -> Vec<u8> {
329        serde_json::to_vec(self).expect("an Envelope always serializes")
330    }
331
332    /// SHA-256 over [`preimage`](Self::preimage)
333    pub fn entry_hash(&self) -> Sha256Hex {
334        Sha256Hex(Sha256::digest(self.preimage()).into())
335    }
336}
337
338/// `t` with anything below a microsecond dropped, so the value hashed is the
339/// value Postgres will store
340pub fn truncate_to_micros(t: DateTime<Utc>) -> DateTime<Utc> {
341    DateTime::from_timestamp_micros(t.timestamp_micros())
342        .expect("a timestamp that came from a DateTime is in range")
343}
344
345/// `t` with anything below a second dropped, so `signed_at` round-trips to
346/// the integer the signature covers
347pub fn truncate_to_seconds(t: DateTime<Utc>) -> DateTime<Utc> {
348    DateTime::from_timestamp(t.timestamp(), 0)
349        .expect("a timestamp that came from a DateTime is in range")
350}
351
352/// `true` when the attestation was signed more than [`RETROACTIVE_AFTER`]
353/// after the entry was recorded — history signed after the fact, which
354/// proves the key holder vouches for it now, not that it was signed then
355pub fn is_retroactive(
356    created_at: DateTime<Utc>,
357    signed_at: DateTime<Utc>,
358) -> bool {
359    signed_at - created_at > RETROACTIVE_AFTER
360}
361
362// ---------------------------------------------------------------------------
363// Wire types
364// ---------------------------------------------------------------------------
365
366/// What the server attests about one governance log entry
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
369#[cfg_attr(feature = "schemars", schemars(inline))]
370pub struct GovernanceAttestation {
371    /// Envelope version; see the module docs for what `1` commits to
372    pub envelope_version: u32,
373    /// Position in the chain, from 1. An index, not part of the envelope:
374    /// the `prev_hash` links are what prove order.
375    pub chain_seq: u64,
376    /// `entry_hash` of the previous entry; `null` only for the first
377    pub prev_hash: Option<Sha256Hex>,
378    /// SHA-256 of the entry's canonical `data`
379    pub data_hash: Sha256Hex,
380    /// SHA-256 of the envelope; what the signature covers
381    pub entry_hash: Sha256Hex,
382    /// Ed25519 over `entry_hash` and `signed_at`, by the platform's
383    /// governance signing key
384    pub signature: SignatureHex,
385    /// When the signature was made. Distinct from `created_at`: see
386    /// `retroactive`.
387    pub signed_at: DateTime<Utc>,
388    /// `true` when signed well after the entry was recorded — the entries
389    /// that predate signing were attested this way, which proves the
390    /// Steward vouches for them, not that they were signed at the time
391    pub retroactive: bool,
392}
393
394/// One link of the chain as `GET /api/governance/log/chain` returns it —
395/// everything needed to verify linkage and signatures, without `data`
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
398#[cfg_attr(feature = "schemars", schemars(inline))]
399pub struct GovernanceChainLink {
400    pub id: GovernanceLogId,
401    pub entry_type: GovernanceLogEntryType,
402    pub created_at: DateTime<Utc>,
403    pub attestation: GovernanceAttestation,
404}
405
406/// The platform's governance signing key, as `GET
407/// /api/governance/signing-key` publishes it
408#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
409#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
410#[cfg_attr(feature = "schemars", schemars(inline))]
411pub struct GovernanceSigningKey {
412    /// Always `"ed25519"`
413    pub algorithm: String,
414    pub public_key: PublicKeyHex,
415    /// The envelope version entries are currently signed under
416    pub envelope_version: u32,
417}
418
419impl GovernanceSigningKey {
420    /// The published form of `key`
421    pub fn new(key: &VerifyingKey) -> Self {
422        Self {
423            algorithm: "ed25519".to_string(),
424            public_key: key.into(),
425            envelope_version: ENVELOPE_VERSION,
426        }
427    }
428}
429
430/// The verdict on one entry
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
433#[cfg_attr(feature = "schemars", schemars(inline))]
434pub struct EntryVerdict {
435    pub id: GovernanceLogId,
436    pub chain_seq: u64,
437    /// The signature verifies over `entry_hash` and `signed_at` under the
438    /// published key
439    pub signature_valid: bool,
440    /// `entry_hash` recomputes from the envelope fields, `prev_hash` is the
441    /// previous entry's `entry_hash`, and `chain_seq` is contiguous
442    pub link_valid: bool,
443    /// The entry's current `data` hashes to the attested `data_hash`.
444    /// `null` when the verifier did not read `data` (the chain endpoint
445    /// carries none). `false` with a clean chain means the content was
446    /// changed after attestation — look for an amendment entry naming it.
447    #[serde(default)]
448    pub content_matches: Option<bool>,
449    /// See [`GovernanceAttestation::retroactive`]
450    pub retroactive: bool,
451    /// `created_at` is earlier than the previous link's. Informational:
452    /// chain order is what is attested, and a clock step does not break it.
453    pub out_of_order: bool,
454    /// Amendment entries that name this one. Empty until amendments exist.
455    #[serde(default)]
456    pub amended_by: Vec<GovernanceLogId>,
457    /// What failed, when something did
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub problem: Option<String>,
460}
461
462/// A verification of the whole chain
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
464#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
465#[cfg_attr(feature = "schemars", schemars(inline))]
466pub struct GovernanceVerification {
467    pub public_key: PublicKeyHex,
468    /// Every entry's signature and link verified, and no entry's content
469    /// is known to differ from what was attested
470    pub ok: bool,
471    /// The last entry in the chain
472    #[serde(default)]
473    pub head: Option<GovernanceLogId>,
474    /// In chain order
475    pub entries: Vec<EntryVerdict>,
476}
477
478impl GovernanceVerification {
479    /// Recompute `ok` from the entries
480    pub fn settle(mut self) -> Self {
481        self.ok = self.entries.iter().all(|e| {
482            e.signature_valid
483                && e.link_valid
484                && e.content_matches != Some(false)
485        });
486        self
487    }
488}
489
490// ---------------------------------------------------------------------------
491// Signing
492// ---------------------------------------------------------------------------
493
494/// Attest an entry: the one construction path for
495/// [`GovernanceAttestation`], used by the server at insert and by tests
496/// building fixtures.
497///
498/// `signed_at` is truncated to whole seconds, the precision the signature
499/// covers; store the value the attestation carries, not the one passed in.
500pub fn attest(
501    key: &SigningKey,
502    envelope: &Envelope,
503    chain_seq: u64,
504    signed_at: DateTime<Utc>,
505) -> GovernanceAttestation {
506    let signed_at = truncate_to_seconds(signed_at);
507    let entry_hash = envelope.entry_hash();
508    let signature =
509        crypto::sign(key, entry_hash.as_bytes(), signed_at.timestamp());
510    GovernanceAttestation {
511        envelope_version: envelope.agora_governance_log,
512        chain_seq,
513        prev_hash: envelope.prev_hash,
514        data_hash: envelope.data_hash,
515        entry_hash,
516        signature: signature.into(),
517        signed_at,
518        retroactive: is_retroactive(envelope.created_at(), signed_at),
519    }
520}
521
522// ---------------------------------------------------------------------------
523// Verification
524// ---------------------------------------------------------------------------
525
526/// Why one link failed on its own, before chain context
527#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
528pub enum LinkError {
529    #[error(
530        "envelope version {0} is not supported (this verifier knows {ENVELOPE_VERSION})"
531    )]
532    UnsupportedVersion(u32),
533    #[error("entry_hash does not recompute from the envelope fields")]
534    HashMismatch,
535    #[error("signature does not verify under the published key")]
536    BadSignature,
537}
538
539/// Recompute a link's `entry_hash` from its fields
540pub fn recompute_entry_hash(link: &GovernanceChainLink) -> Sha256Hex {
541    Envelope::new(
542        link.id.clone(),
543        link.entry_type,
544        link.created_at,
545        link.attestation.prev_hash,
546        link.attestation.data_hash,
547    )
548    .entry_hash()
549}
550
551/// Verify one link in isolation: version, hash recomputation, signature
552pub fn verify_link(
553    link: &GovernanceChainLink,
554    key: &VerifyingKey,
555) -> Result<(), LinkError> {
556    let a = &link.attestation;
557    if a.envelope_version != ENVELOPE_VERSION {
558        return Err(LinkError::UnsupportedVersion(a.envelope_version));
559    }
560    if recompute_entry_hash(link) != a.entry_hash {
561        return Err(LinkError::HashMismatch);
562    }
563    if !crypto::verify(
564        key,
565        a.entry_hash.as_bytes(),
566        a.signed_at.timestamp(),
567        &Signature::from(&a.signature),
568    ) {
569        return Err(LinkError::BadSignature);
570    }
571    Ok(())
572}
573
574/// `true` when `data` is what `link` attested
575pub fn verify_data(
576    link: &GovernanceChainLink,
577    data: &serde_json::Value,
578) -> bool {
579    data_hash(data) == link.attestation.data_hash
580}
581
582/// Verify a whole chain under `key`.
583///
584/// Links are sorted by `chain_seq` first, so the caller's order does not
585/// matter. `content_matches` is `None` throughout: a chain carries no
586/// `data`; see [`verify_data`] for that half. `retroactive` and
587/// `out_of_order` are recomputed from the timestamps, not copied.
588pub fn verify_chain(
589    links: &[GovernanceChainLink],
590    key: &VerifyingKey,
591) -> GovernanceVerification {
592    let mut links: Vec<&GovernanceChainLink> = links.iter().collect();
593    links.sort_by_key(|l| l.attestation.chain_seq);
594
595    let mut entries = Vec::with_capacity(links.len());
596    let mut prev: Option<&GovernanceChainLink> = None;
597    for (i, link) in links.iter().enumerate() {
598        let a = &link.attestation;
599        let expected_seq = i as u64 + 1;
600        let mut problems: Vec<String> = Vec::new();
601
602        let (hash_ok, signature_valid) = match verify_link(link, key) {
603            Ok(()) => (true, true),
604            Err(LinkError::BadSignature) => {
605                problems.push(LinkError::BadSignature.to_string());
606                (true, false)
607            }
608            Err(e) => {
609                problems.push(e.to_string());
610                (false, false)
611            }
612        };
613
614        let mut link_valid = hash_ok;
615        if a.chain_seq != expected_seq {
616            link_valid = false;
617            problems.push(format!(
618                "chain_seq {} where {expected_seq} was expected",
619                a.chain_seq
620            ));
621        }
622        let expected_prev = prev.map(|p| p.attestation.entry_hash);
623        if a.prev_hash != expected_prev {
624            link_valid = false;
625            problems.push(match (a.prev_hash, expected_prev) {
626                (Some(_), None) => "first entry names a predecessor".into(),
627                (None, Some(_)) => "prev_hash is null mid-chain".into(),
628                _ => "prev_hash is not the previous entry's entry_hash".into(),
629            });
630        }
631        let out_of_order = prev.is_some_and(|p| link.created_at < p.created_at);
632
633        entries.push(EntryVerdict {
634            id: link.id.clone(),
635            chain_seq: a.chain_seq,
636            signature_valid,
637            link_valid,
638            content_matches: None,
639            retroactive: is_retroactive(link.created_at, a.signed_at),
640            out_of_order,
641            amended_by: Vec::new(),
642            problem: (!problems.is_empty()).then(|| problems.join("; ")),
643        });
644        prev = Some(link);
645    }
646
647    GovernanceVerification {
648        public_key: key.into(),
649        ok: false,
650        head: prev.map(|p| p.id.clone()),
651        entries,
652    }
653    .settle()
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::crypto::generate_keypair;
660    use serde_json::json;
661
662    fn gov(n: u32) -> GovernanceLogId {
663        format!("GOV-2026-{n:04}").parse().unwrap()
664    }
665
666    fn at(secs: i64) -> DateTime<Utc> {
667        DateTime::from_timestamp(1_700_000_000 + secs, 123_456_789).unwrap()
668    }
669
670    fn link(
671        key: &SigningKey,
672        n: u32,
673        prev: Option<&GovernanceChainLink>,
674        data: &serde_json::Value,
675        signed_at: DateTime<Utc>,
676    ) -> GovernanceChainLink {
677        let created_at = truncate_to_micros(at(n as i64 * 10));
678        let envelope = Envelope::new(
679            gov(n),
680            GovernanceLogEntryType::CouncilDecision,
681            created_at,
682            prev.map(|p| p.attestation.entry_hash),
683            data_hash(data),
684        );
685        let attestation = attest(
686            key,
687            &envelope,
688            prev.map_or(1, |p| p.attestation.chain_seq + 1),
689            signed_at,
690        );
691        GovernanceChainLink {
692            id: gov(n),
693            entry_type: GovernanceLogEntryType::CouncilDecision,
694            created_at,
695            attestation,
696        }
697    }
698
699    fn chain(key: &SigningKey, n: u32) -> Vec<GovernanceChainLink> {
700        let mut out: Vec<GovernanceChainLink> = Vec::new();
701        for i in 1..=n {
702            let data = json!({"title": format!("Decision {i}"), "outcome": "approved"});
703            let l = link(key, i, out.last(), &data, at(i as i64 * 10 + 1));
704            out.push(l);
705        }
706        out
707    }
708
709    // -- canonical JSON --
710
711    #[test]
712    fn canonical_json_sorts_keys_at_every_level() {
713        let v = json!({"b": {"z": 1, "a": [{"y": 2, "x": 3}]}, "a": null});
714        assert_eq!(
715            canonical_json(&v),
716            br#"{"a":null,"b":{"a":[{"x":3,"y":2}],"z":1}}"#
717        );
718    }
719
720    #[test]
721    fn canonical_json_is_compact_and_escapes_like_serde() {
722        let v = json!({"s": "tab\there \"q\" ünïcode \u{1F600}", "n": [1, -2, 3.5, true, false]});
723        let bytes = canonical_json(&v);
724        let text = std::str::from_utf8(&bytes).unwrap();
725        assert_eq!(
726            text,
727            r#"{"n":[1,-2,3.5,true,false],"s":"tab\there \"q\" ünïcode 😀"}"#
728        );
729    }
730
731    #[test]
732    fn canonical_json_ignores_insertion_order() {
733        let mut a = serde_json::Map::new();
734        a.insert("z".into(), json!(1));
735        a.insert("a".into(), json!(2));
736        let mut b = serde_json::Map::new();
737        b.insert("a".into(), json!(2));
738        b.insert("z".into(), json!(1));
739        assert_eq!(
740            canonical_json(&serde_json::Value::Object(a)),
741            canonical_json(&serde_json::Value::Object(b))
742        );
743    }
744
745    #[test]
746    fn canonical_json_empty_containers() {
747        assert_eq!(canonical_json(&json!({})), b"{}");
748        assert_eq!(canonical_json(&json!([])), b"[]");
749        assert_eq!(
750            canonical_json(&json!({"a": {}, "b": []})),
751            br#"{"a":{},"b":[]}"#
752        );
753    }
754
755    // -- envelope --
756
757    #[test]
758    fn preimage_is_declaration_ordered_json() {
759        let e = Envelope::new(
760            gov(1),
761            GovernanceLogEntryType::AppealsCourtDecision,
762            at(0),
763            None,
764            data_hash(&json!({})),
765        );
766        let text = String::from_utf8(e.preimage()).unwrap();
767        assert!(text.starts_with(r#"{"agora_governance_log":1,"id":"GOV-2026-0001","entry_type":"appeals_court_decision","created_at":1700000000123456,"prev_hash":null,"data_hash":""#), "{text}");
768    }
769
770    #[test]
771    fn every_envelope_field_changes_the_hash() {
772        let base = Envelope::new(
773            gov(1),
774            GovernanceLogEntryType::CouncilDecision,
775            at(0),
776            None,
777            data_hash(&json!({"a":1})),
778        );
779        let h = base.entry_hash();
780        let mut e = base.clone();
781        e.id = gov(2);
782        assert_ne!(e.entry_hash(), h);
783        let mut e = base.clone();
784        e.entry_type = GovernanceLogEntryType::PolicyChange;
785        assert_ne!(e.entry_hash(), h);
786        let mut e = base.clone();
787        e.created_at += 1;
788        assert_ne!(e.entry_hash(), h);
789        let mut e = base.clone();
790        e.prev_hash = Some(h);
791        assert_ne!(e.entry_hash(), h);
792        let mut e = base.clone();
793        e.data_hash = data_hash(&json!({"a":2}));
794        assert_ne!(e.entry_hash(), h);
795        assert_eq!(base.entry_hash(), h, "and it is deterministic");
796    }
797
798    #[test]
799    fn truncation_matches_what_the_envelope_carries() {
800        let t = at(0);
801        assert_eq!(truncate_to_micros(t).timestamp_subsec_nanos(), 123_456_000);
802        assert_eq!(truncate_to_seconds(t).timestamp_subsec_nanos(), 0);
803        assert_eq!(
804            Envelope::new(
805                gov(1),
806                GovernanceLogEntryType::CouncilDecision,
807                t,
808                None,
809                data_hash(&json!(null))
810            )
811            .created_at,
812            truncate_to_micros(t).timestamp_micros()
813        );
814    }
815
816    // -- hex newtypes --
817
818    #[test]
819    fn hex_newtypes_round_trip_and_reject_wrong_lengths() {
820        let h = data_hash(&json!(1));
821        let s = serde_json::to_string(&h).unwrap();
822        assert_eq!(s.len(), 66);
823        let back: Sha256Hex = serde_json::from_str(&s).unwrap();
824        assert_eq!(back, h);
825        assert!(serde_json::from_str::<Sha256Hex>("\"abcd\"").is_err());
826        assert!("zz".repeat(32).parse::<Sha256Hex>().is_err());
827        assert!(Sha256Hex::try_from(vec![0u8; 31]).is_err());
828        assert_eq!(format!("{h:?}"), format!("Sha256Hex({h})"));
829    }
830
831    // -- signing and verification --
832
833    #[test]
834    fn attest_then_verify_link() {
835        let (key, pk) = generate_keypair();
836        let l = link(&key, 1, None, &json!({"a": 1}), at(5));
837        assert_eq!(verify_link(&l, &pk), Ok(()));
838        assert!(verify_data(&l, &json!({"a": 1})));
839        assert!(!verify_data(&l, &json!({"a": 2})));
840        assert!(!l.attestation.retroactive);
841    }
842
843    #[test]
844    fn wrong_key_fails_signature_only() {
845        let (key, _) = generate_keypair();
846        let (_, other) = generate_keypair();
847        let l = link(&key, 1, None, &json!({}), at(5));
848        assert_eq!(verify_link(&l, &other), Err(LinkError::BadSignature));
849    }
850
851    #[test]
852    fn tampering_with_any_attested_field_is_detected() {
853        let (key, pk) = generate_keypair();
854        let l = link(&key, 1, None, &json!({"a": 1}), at(5));
855
856        let mut t = l.clone();
857        t.created_at += chrono::Duration::microseconds(1);
858        assert_eq!(verify_link(&t, &pk), Err(LinkError::HashMismatch));
859
860        let mut t = l.clone();
861        t.entry_type = GovernanceLogEntryType::StewardVeto;
862        assert_eq!(verify_link(&t, &pk), Err(LinkError::HashMismatch));
863
864        let mut t = l.clone();
865        t.attestation.data_hash = data_hash(&json!({"a": 2}));
866        assert_eq!(verify_link(&t, &pk), Err(LinkError::HashMismatch));
867
868        // Back-dating the signature: the hash still recomputes, but the
869        // signature covered the real signed_at.
870        let mut t = l.clone();
871        t.attestation.signed_at -= chrono::Duration::seconds(1);
872        assert_eq!(verify_link(&t, &pk), Err(LinkError::BadSignature));
873
874        let mut t = l.clone();
875        t.attestation.envelope_version = 2;
876        assert_eq!(verify_link(&t, &pk), Err(LinkError::UnsupportedVersion(2)));
877    }
878
879    #[test]
880    fn a_good_chain_verifies_in_any_input_order() {
881        let (key, pk) = generate_keypair();
882        let mut c = chain(&key, 4);
883        c.reverse();
884        let v = verify_chain(&c, &pk);
885        assert!(v.ok, "{v:#?}");
886        assert_eq!(v.head, Some(gov(4)));
887        assert_eq!(
888            v.entries.iter().map(|e| e.chain_seq).collect::<Vec<_>>(),
889            [1, 2, 3, 4]
890        );
891        assert!(v.entries.iter().all(|e| e.content_matches.is_none()
892            && e.problem.is_none()
893            && !e.out_of_order));
894        assert_eq!(v.public_key, PublicKeyHex::from(&pk));
895    }
896
897    #[test]
898    fn empty_chain_is_ok_with_no_head() {
899        let (_, pk) = generate_keypair();
900        let v = verify_chain(&[], &pk);
901        assert!(v.ok);
902        assert!(v.head.is_none());
903        assert!(v.entries.is_empty());
904    }
905
906    #[test]
907    fn a_changed_entry_breaks_its_signature_and_the_next_link() {
908        let (key, pk) = generate_keypair();
909        let mut c = chain(&key, 3);
910        // Re-attest entry 2 with different data but the same predecessor,
911        // as a key holder rewriting history would.
912        let rewritten = link(
913            &key,
914            2,
915            Some(&c[0]),
916            &json!({"title": "Decision 2", "outcome": "REJECTED"}),
917            at(21),
918        );
919        c[1] = rewritten;
920        let v = verify_chain(&c, &pk);
921        assert!(!v.ok);
922        assert!(
923            v.entries[1].signature_valid && v.entries[1].link_valid,
924            "the rewrite itself is well-formed: {:#?}",
925            v.entries[1]
926        );
927        assert!(
928            !v.entries[2].link_valid,
929            "but entry 3 no longer points at it: {:#?}",
930            v.entries[2]
931        );
932        assert!(
933            v.entries[2]
934                .problem
935                .as_deref()
936                .unwrap()
937                .contains("prev_hash")
938        );
939    }
940
941    #[test]
942    fn a_removed_entry_is_a_gap_and_a_broken_link() {
943        let (key, pk) = generate_keypair();
944        let mut c = chain(&key, 3);
945        c.remove(1);
946        let v = verify_chain(&c, &pk);
947        assert!(!v.ok);
948        assert!(v.entries[0].link_valid);
949        let p = v.entries[1].problem.as_deref().unwrap();
950        assert!(p.contains("chain_seq 3 where 2 was expected"), "{p}");
951        assert!(p.contains("prev_hash"), "{p}");
952    }
953
954    #[test]
955    fn a_second_genesis_is_rejected() {
956        let (key, pk) = generate_keypair();
957        let mut c = chain(&key, 2);
958        let rogue = link(&key, 2, None, &json!({}), at(21));
959        c[1] = rogue;
960        let v = verify_chain(&c, &pk);
961        assert!(!v.ok);
962        assert!(
963            v.entries[1]
964                .problem
965                .as_deref()
966                .unwrap()
967                .contains("null mid-chain")
968        );
969    }
970
971    #[test]
972    fn retroactive_and_out_of_order_are_recomputed_not_copied() {
973        let (key, pk) = generate_keypair();
974        let first = link(&key, 1, None, &json!({}), at(10 + 3600));
975        // Second entry recorded *before* the first by the clock, but after it
976        // in the chain. Valid chain, flagged order.
977        let created = truncate_to_micros(at(5));
978        let envelope = Envelope::new(
979            gov(2),
980            GovernanceLogEntryType::CouncilDecision,
981            created,
982            Some(first.attestation.entry_hash),
983            data_hash(&json!({})),
984        );
985        let attestation = attest(&key, &envelope, 2, at(6));
986        let mut second = GovernanceChainLink {
987            id: gov(2),
988            entry_type: GovernanceLogEntryType::CouncilDecision,
989            created_at: created,
990            attestation,
991        };
992        second.attestation.retroactive = true; // a lying flag on the wire
993        let v = verify_chain(&[first, second], &pk);
994        assert!(v.ok, "{v:#?}");
995        assert!(v.entries[0].retroactive);
996        assert!(!v.entries[1].retroactive, "recomputed from timestamps");
997        assert!(v.entries[1].out_of_order);
998    }
999
1000    #[test]
1001    fn content_mismatch_settles_to_not_ok() {
1002        let (key, pk) = generate_keypair();
1003        let c = chain(&key, 1);
1004        let mut v = verify_chain(&c, &pk);
1005        v.entries[0].content_matches = Some(true);
1006        assert!(v.clone().settle().ok);
1007        v.entries[0].content_matches = Some(false);
1008        assert!(!v.settle().ok);
1009    }
1010
1011    #[cfg(feature = "schemars")]
1012    #[test]
1013    fn wire_schemas_are_ref_free() {
1014        use crate::responses::inline_schema_for;
1015        for (name, schema) in [
1016            (
1017                "GovernanceAttestation",
1018                inline_schema_for::<GovernanceAttestation>(),
1019            ),
1020            (
1021                "GovernanceChainLink",
1022                inline_schema_for::<GovernanceChainLink>(),
1023            ),
1024            (
1025                "GovernanceSigningKey",
1026                inline_schema_for::<GovernanceSigningKey>(),
1027            ),
1028            (
1029                "GovernanceVerification",
1030                inline_schema_for::<GovernanceVerification>(),
1031            ),
1032            (
1033                "Vec<GovernanceChainLink>",
1034                inline_schema_for::<Vec<GovernanceChainLink>>(),
1035            ),
1036        ] {
1037            let text = serde_json::to_string(&schema).unwrap();
1038            assert!(!text.contains("$ref"), "{name} must be $ref-free: {text}");
1039            assert!(
1040                !text.contains("$defs"),
1041                "{name} must be $defs-free: {text}"
1042            );
1043        }
1044        let text =
1045            serde_json::to_string(&inline_schema_for::<Sha256Hex>()).unwrap();
1046        assert!(text.contains("^[0-9a-f]{64}$"), "{text}");
1047        let text = serde_json::to_string(&inline_schema_for::<SignatureHex>())
1048            .unwrap();
1049        assert!(text.contains("^[0-9a-f]{128}$"), "{text}");
1050    }
1051}